Repository: antonlorani/jottre
Branch: master
Commit: a4ac34d3b5e3
Files: 289
Total size: 946.0 KB
Directory structure:
gitextract_ecdvzt6x/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ └── workflows/
│ ├── pull_request.yml
│ └── push_on_master.yml
├── .gitignore
├── .license_header_template
├── .licenseignore
├── .rubocop.yml
├── .ruby-version
├── .swift-format
├── .xcode-version
├── AppKitPlugin/
│ ├── AppKitPlugin.swift
│ └── Info.plist
├── Brewfile
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Gemfile
├── LICENSE
├── PRIVACY_POLICY.md
├── README.md
├── Resources/
│ ├── Assets.xcassets/
│ │ └── AppIcon.appiconset/
│ │ └── Contents.json
│ ├── Localizable.xcstrings
│ └── PrivacyInfo.xcprivacy
├── SECURITY_POLICY.md
├── Sources/
│ ├── AppDelegate.swift
│ ├── ApplicationService.swift
│ ├── BundleService.swift
│ ├── CloudMigrationPage/
│ │ ├── CloudImageCell/
│ │ │ ├── CloudImageCell.swift
│ │ │ ├── CloudImageCellViewModel.swift
│ │ │ └── PageCellItem+cloudImage.swift
│ │ ├── CloudMigrationCoordinator.swift
│ │ ├── CloudMigrationCoordinatorFactory.swift
│ │ ├── CloudMigrationJotBusinessModel.swift
│ │ ├── CloudMigrationJotCell/
│ │ │ ├── CloudMigrationJotCell.swift
│ │ │ ├── CloudMigrationJotCellViewModel.swift
│ │ │ └── PageCellItem+cloudMigrationJot.swift
│ │ ├── CloudMigrationRepository.swift
│ │ ├── CloudMigrationViewControllerFactory.swift
│ │ ├── CloudMigrationViewModel.swift
│ │ ├── DefaultsKey+hasDoneCloudMigration.swift
│ │ └── DefaultsKey+isICloudEnabled.swift
│ ├── Defaults/
│ │ ├── DefaultsContinuationStorage.swift
│ │ ├── DefaultsKey.swift
│ │ └── DefaultsService.swift
│ ├── DesignTokens.swift
│ ├── DeviceService.swift
│ ├── EditJotPage/
│ │ ├── EditJotCoordinator.swift
│ │ ├── EditJotCoordinatorFactory.swift
│ │ ├── EditJotRepository.swift
│ │ ├── EditJotURL.swift
│ │ ├── EditJotViewController.swift
│ │ ├── EditJotViewControllerFactory.swift
│ │ └── EditJotViewModel.swift
│ ├── EnableCloudPage/
│ │ ├── EnableCloudCoordinator.swift
│ │ ├── EnableCloudCoordinatorFactory.swift
│ │ ├── EnableCloudViewControllerFactory.swift
│ │ ├── EnableCloudViewModel.swift
│ │ └── FeatureRow/
│ │ ├── FeatureRowCell.swift
│ │ ├── FeatureRowCellViewModel.swift
│ │ └── PageCellItem+featureRow.swift
│ ├── FileConflictService.swift
│ ├── FileService/
│ │ ├── FileServiceProtocol.swift
│ │ ├── LocalFileService.swift
│ │ ├── UbiquitousFileService.swift
│ │ └── UbiquitousInfo.swift
│ ├── InfoAlertCoordinator.swift
│ ├── Jot/
│ │ ├── Jot.swift
│ │ ├── JotFile.swift
│ │ ├── JotFileService.swift
│ │ └── JotFileVersion.swift
│ ├── JotConflictPage/
│ │ ├── JotConflictCell/
│ │ │ ├── JotConflictBusinessModel.swift
│ │ │ ├── JotConflictCell.swift
│ │ │ ├── JotConflictCellViewModel.swift
│ │ │ └── PageCellItem+jotConflict.swift
│ │ ├── JotConflictCoordinator.swift
│ │ ├── JotConflictCoordinatorFactory.swift
│ │ ├── JotConflictRepository.swift
│ │ ├── JotConflictResult.swift
│ │ ├── JotConflictViewControllerFactory.swift
│ │ ├── JotConflictViewModel.swift
│ │ └── JotFileConflictService.swift
│ ├── JotFilePreview/
│ │ ├── CachedJotFilePreviewImageService.swift
│ │ ├── JotFilePreviewImageService.swift
│ │ └── JotFilePreviewImageServiceProtocol.swift
│ ├── JotsPage/
│ │ ├── CreateJot/
│ │ │ ├── CreateJotCoordinator.swift
│ │ │ ├── CreateJotCoordinatorFactory.swift
│ │ │ └── CreateJotRepository.swift
│ │ ├── DeleteJot/
│ │ │ ├── DeleteJotCoordinator.swift
│ │ │ ├── DeleteJotCoordinatorFactory.swift
│ │ │ └── DeleteJotRepository.swift
│ │ ├── EmptyStateCell/
│ │ │ ├── EmptyStateCell.swift
│ │ │ ├── EmptyStateViewModel.swift
│ │ │ └── PageCellItem+jotsEmptyState.swift
│ │ ├── JotCell/
│ │ │ ├── JotBusinessModel.swift
│ │ │ ├── JotCell.swift
│ │ │ ├── JotCellViewModel.swift
│ │ │ └── PageCellItem+jot.swift
│ │ ├── JotMenuConfiguration.swift
│ │ ├── JotMenuConfigurationFactory.swift
│ │ ├── JotsCoordinator.swift
│ │ ├── JotsCoordinatorFactory.swift
│ │ ├── JotsPageURL.swift
│ │ ├── JotsRepository.swift
│ │ ├── JotsViewControllerFactory.swift
│ │ ├── JotsViewModel.swift
│ │ ├── RenameJot/
│ │ │ ├── RenameJotCoordinator.swift
│ │ │ ├── RenameJotCoordinatorFactory.swift
│ │ │ └── RenameJotRepository.swift
│ │ ├── ShareJot/
│ │ │ ├── ShareJotCoordinator.swift
│ │ │ ├── ShareJotCoordinatorFactory.swift
│ │ │ └── ShareJotRepository.swift
│ │ └── UIMenu+makeJotMenuConfiguration.swift
│ ├── L10n.swift
│ ├── MacCatalystAppKitPluginService.swift
│ ├── Navigation/
│ │ ├── Coordinator.swift
│ │ ├── Navigation.swift
│ │ ├── NavigationCoordinator.swift
│ │ └── URLConvertible.swift
│ ├── PageViewController/
│ │ ├── Cell/
│ │ │ ├── PageCell.swift
│ │ │ ├── PageCellAction.swift
│ │ │ ├── PageCellItem.swift
│ │ │ ├── PageCellSizingStrategy.swift
│ │ │ └── PageCellViewModel.swift
│ │ ├── PageCallToActionView.swift
│ │ ├── PageHeader/
│ │ │ ├── PageCellItem+pageHeader.swift
│ │ │ ├── PageHeaderCell.swift
│ │ │ └── PageHeaderViewModel.swift
│ │ ├── PageNavigationItem.swift
│ │ ├── PageNavigationSymbolBarButtonItemFactory.swift
│ │ ├── PageNavigationTextBarButtonItemFactory.swift
│ │ ├── PageViewController.swift
│ │ └── PageViewModel.swift
│ ├── RevealFile/
│ │ ├── RevealFileCoordinator.swift
│ │ ├── RevealFileCoordinatorFactory.swift
│ │ └── RevealFileURL.swift
│ ├── RootCoordinator.swift
│ ├── RootCoordinatorFactory.swift
│ ├── SceneCoordinator.swift
│ ├── SceneDelegate.swift
│ ├── SettingsPage/
│ │ ├── DefaultsKey+userInterfaceStyle.swift
│ │ ├── DropDownCell/
│ │ │ ├── PageCellItem+settingsDropdown.swift
│ │ │ ├── SettingsDropdownBusinessModel.swift
│ │ │ ├── SettingsDropdownCell.swift
│ │ │ └── SettingsDropdownCellViewModel.swift
│ │ ├── EnableICloudSupportURL.swift
│ │ ├── ExternalLinkCell/
│ │ │ ├── PageCellItem+settingsExternalLink.swift
│ │ │ ├── SettingsExternalLinkBusinessModel.swift
│ │ │ ├── SettingsExternalLinkCell.swift
│ │ │ └── SettingsExternalLinkCellViewModel.swift
│ │ ├── InfoCell/
│ │ │ ├── PageCellItem+settingsInfo.swift
│ │ │ ├── SettingsInfoBusinessModel.swift
│ │ │ ├── SettingsInfoCell.swift
│ │ │ └── SettingsInfoCellViewModel.swift
│ │ ├── JottreGithubURL.swift
│ │ ├── SettingsCell/
│ │ │ └── SettingsCell.swift
│ │ ├── SettingsCoordinator.swift
│ │ ├── SettingsCoordinatorFactory.swift
│ │ ├── SettingsRepository.swift
│ │ ├── SettingsViewControllerFactory.swift
│ │ ├── SettingsViewModel.swift
│ │ └── ToggleCell/
│ │ ├── PageCellItem+settingsToggle.swift
│ │ ├── SettingsToggleBusinessModel.swift
│ │ ├── SettingsToggleCell.swift
│ │ └── SettingsToggleCellViewModel.swift
│ └── Utilities/
│ ├── Array+safeIndex.swift
│ ├── AsyncSequence+toAsyncThrowingStream.swift
│ ├── AsyncStream+debounce.swift
│ ├── LoggerProtocol.swift
│ ├── NSLayoutConstraint+withPriority.swift
│ ├── UIColor+adaptiveBlackWhite.swift
│ ├── UIFont+systemStyle.swift
│ └── UITraitCollection+hasRenderingChange.swift
├── Tests/
│ ├── CloudMigrationPage/
│ │ ├── CloudImageCellViewModelTests.swift
│ │ ├── CloudMigrationCoordinatorTests.swift
│ │ ├── CloudMigrationJotBusinessModelTests.swift
│ │ ├── CloudMigrationJotCellViewModelTests.swift
│ │ ├── CloudMigrationRepositoryTests.swift
│ │ └── CloudMigrationViewModelTests.swift
│ ├── Defaults/
│ │ ├── DefaultsContinuationStorageTests.swift
│ │ ├── DefaultsKeyTests.swift
│ │ └── DefaultsServiceTests.swift
│ ├── EditJotPage/
│ │ ├── EditJotCoordinatorTests.swift
│ │ ├── EditJotRepositoryTests.swift
│ │ └── EditJotViewModelTests.swift
│ ├── EnableCloudPage/
│ │ ├── EnableCloudCoordinatorTests.swift
│ │ ├── EnableCloudViewModelTests.swift
│ │ └── FeatureRowCellViewModelTests.swift
│ ├── FileService/
│ │ ├── LocalFileServiceTests.swift
│ │ └── UbiquitousInfoTests.swift
│ ├── Helpers/
│ │ ├── Navigation+test.swift
│ │ ├── UIAlertAction+invoke.swift
│ │ └── URL+staticString.swift
│ ├── Jot/
│ │ ├── JotFileServiceDocumentsDirectoryContentsTests.swift
│ │ ├── JotFileServiceTests.swift
│ │ └── JotFileTests.swift
│ ├── JotConflictPage/
│ │ ├── JotConflictBusinessModelTests.swift
│ │ ├── JotConflictCellViewModelTests.swift
│ │ ├── JotConflictCoordinatorTests.swift
│ │ ├── JotConflictRepositoryTests.swift
│ │ ├── JotConflictViewModelTests.swift
│ │ └── JotFileConflictServiceTests.swift
│ ├── JotFilePreview/
│ │ └── CachedJotFilePreviewImageServiceTests.swift
│ ├── JotsPage/
│ │ ├── CreateJotCoordinatorTests.swift
│ │ ├── CreateJotRepositoryTests.swift
│ │ ├── DeleteJotCoordinatorTests.swift
│ │ ├── DeleteJotRepositoryTests.swift
│ │ ├── EmptyStateCellViewModelTests.swift
│ │ ├── JotBusinessModelTests.swift
│ │ ├── JotCellViewModelTests.swift
│ │ ├── JotMenuConfigurationFactoryTests.swift
│ │ ├── JotsCoordinatorTests.swift
│ │ ├── JotsRepositoryTests.swift
│ │ ├── JotsViewModelTests.swift
│ │ ├── RenameJotCoordinatorTests.swift
│ │ ├── RenameJotRepositoryTests.swift
│ │ ├── ShareJotCoordinatorTests.swift
│ │ └── ShareJotRepositoryTests.swift
│ ├── Mocks/
│ │ ├── ApplicationServiceMock.swift
│ │ ├── BundleServiceMock.swift
│ │ ├── CloudMigrationCoordinatorMock.swift
│ │ ├── CloudMigrationRepositoryMock.swift
│ │ ├── CloudMigrationViewControllerFactoryMock.swift
│ │ ├── CoordinatorFactoryMocks.swift
│ │ ├── CoordinatorMock.swift
│ │ ├── CreateJotRepositoryMock.swift
│ │ ├── DefaultsServiceMock.swift
│ │ ├── DeleteJotRepositoryMock.swift
│ │ ├── DeviceServiceMock.swift
│ │ ├── EditJotCoordinatorMock.swift
│ │ ├── EditJotRepositoryMock.swift
│ │ ├── EditJotViewControllerFactoryMock.swift
│ │ ├── EnableCloudCoordinatorMock.swift
│ │ ├── EnableCloudViewControllerFactoryMock.swift
│ │ ├── FileConflictServiceMock.swift
│ │ ├── FileServiceMock.swift
│ │ ├── JotConflictCoordinatorMock.swift
│ │ ├── JotConflictRepositoryMock.swift
│ │ ├── JotConflictViewControllerFactoryMock.swift
│ │ ├── JotFileConflictServiceMock.swift
│ │ ├── JotFilePreviewImageServiceMock.swift
│ │ ├── JotFileServiceMock.swift
│ │ ├── JotsCoordinatorMock.swift
│ │ ├── JotsRepositoryMock.swift
│ │ ├── JotsViewControllerFactoryMock.swift
│ │ ├── LoggerMock.swift
│ │ ├── PageCoordinatorFactoryMocks.swift
│ │ ├── RenameJotRepositoryMock.swift
│ │ ├── SettingsCoordinatorMock.swift
│ │ ├── SettingsRepositoryMock.swift
│ │ ├── SettingsViewControllerFactoryMock.swift
│ │ └── ShareJotRepositoryMock.swift
│ ├── Navigation/
│ │ ├── EditJotURLTests.swift
│ │ ├── EnableICloudSupportURLTests.swift
│ │ ├── JotsPageURLTests.swift
│ │ ├── JottreGithubURLTests.swift
│ │ └── RevealFileURLTests.swift
│ ├── PageViewController/
│ │ ├── IOS18SymbolBarButtonItemFactoryTests.swift
│ │ ├── IOS18TextBarButtonItemFactoryTests.swift
│ │ ├── PageCellSizingStrategyTests.swift
│ │ └── PageHeaderCellViewModelTests.swift
│ ├── Resources/
│ │ └── Calculator Pro.jot
│ ├── RevealFile/
│ │ └── RevealFileCoordinatorTests.swift
│ ├── SettingsPage/
│ │ ├── SettingsCoordinatorTests.swift
│ │ ├── SettingsDropdownCellViewModelTests.swift
│ │ ├── SettingsExternalLinkCellViewModelTests.swift
│ │ ├── SettingsInfoCellViewModelTests.swift
│ │ ├── SettingsRepositoryTests.swift
│ │ ├── SettingsToggleCellViewModelTests.swift
│ │ └── SettingsViewModelTests.swift
│ └── Utilities/
│ ├── Array+safeIndexTests.swift
│ ├── AsyncSequenceDebounceTests.swift
│ ├── AsyncSequenceToAsyncThrowingStreamTests.swift
│ ├── NSLayoutConstraintWithPriorityTests.swift
│ ├── UIColorAdaptiveBlackWhiteTests.swift
│ ├── UIFontPreferredFontTests.swift
│ └── UITraitCollectionHasRenderingChangeTests.swift
├── fastlane/
│ ├── .gitignore
│ ├── Appfile
│ ├── Fastfile
│ ├── Matchfile
│ ├── README.md
│ ├── appstore_metadata.rb
│ ├── appstoreconnect/
│ │ ├── metadata.json
│ │ └── screenshots/
│ │ └── .gitignore
│ ├── export_screenshots.rb
│ └── versioning.rb
├── hooks/
│ ├── install_hooks.sh
│ └── pre-commit
└── project.yml
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: Bug Report
description: Report a reproducible bug in Jottre.
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug report! Please fill out the sections below so we can reproduce and address the issue quickly.
- type: input
id: app_version
attributes:
label: App version
description: The version of Jottre where you observed the bug.
placeholder: e.g. 2.6.8
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: The platform where the bug occurred.
options:
- iOS
- iPadOS
- macOS
validations:
required: true
- type: input
id: platform_version
attributes:
label: Platform version
description: The version of the platform where the bug occurred.
placeholder: e.g. 17.4
validations:
required: true
- type: textarea
id: given
attributes:
label: Given
description: What is the precondition for this bug to occur? Include any additional context not covered by the fields above.
placeholder: Describe the state of the app and any relevant setup before the bug occurs.
validations:
required: true
- type: textarea
id: when
attributes:
label: When
description: What interactions take place for this bug to occur? List the steps as precisely as possible.
placeholder: |
1. Open ...
2. Tap ...
3. ...
validations:
required: true
- type: textarea
id: then
attributes:
label: Then
description: What is the outcome of the described precondition and interaction?
placeholder: Describe the actual behaviour, including any error messages, crashes, or visual glitches.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected
description: What is the wanted behaviour of the precondition and interaction?
placeholder: Describe what you expected to happen instead.
validations:
required: true
- type: textarea
id: impact
attributes:
label: Impact
description: How does this negatively impact your work with Jottre?
placeholder: e.g. blocks note-taking entirely, causes data loss, minor inconvenience, ...
validations:
required: true
- type: textarea
id: media
attributes:
label: Logs, screenshots, or video
description: Attach any logs, screenshots, or screen recordings that help illustrate the bug.
placeholder: Drag and drop files here, or paste logs as text.
validations:
required: false
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
options:
- label: I have read and accept the [code of conduct](../../CODE_OF_CONDUCT.md)
required: true
- label: I have read and accept the [contribution guidelines](../../CONTRIBUTING.md)
required: true
- label: I have already looked at existing issues related to this bug
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yml
================================================
name: Feature Request
description: Suggest a new feature or improvement for Jottre.
labels:
- feature
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a new feature! Please describe the problem it solves, your proposed solution, and the value it brings to users.
- type: textarea
id: problem
attributes:
label: Problem
description: What is missing in the current app/project creating a problem for the user?
placeholder: Describe the gap or pain point you are experiencing today.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Solution
description: What is the proposed change to solve the described problem?
placeholder: Describe the feature, behaviour, or change you would like to see.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What other approaches did you consider, and why is the proposed solution preferred?
placeholder: Describe any workarounds or alternative designs you have thought about.
validations:
required: false
- type: textarea
id: value
attributes:
label: Value
description: What value does this bring to the user?
placeholder: Describe how users will benefit from this change.
validations:
required: true
- type: checkboxes
id: platforms
attributes:
label: Platforms
description: Which platforms should this feature target?
options:
- label: iOS
- label: iPadOS
- label: macOS
validations:
required: true
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
options:
- label: I have read and accept the [code of conduct](../../CODE_OF_CONDUCT.md)
required: true
- label: I have read and accept the [contribution guidelines](../../CONTRIBUTING.md)
required: true
- label: I have already looked at existing issues related to this feature
required: true
================================================
FILE: .github/workflows/pull_request.yml
================================================
name: Pull Request
on:
pull_request:
branches:
- master
types: [opened, reopened, synchronize, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
soundness:
name: Soundness
uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main
with:
api_breakage_check_enabled: false
broken_symlink_check_enabled: false
docs_check_enabled: false
python_lint_check_enabled: false
shell_check_enabled: false
unacceptable_language_check_enabled: false
yamllint_check_enabled: false
lint-ruby:
name: Lint Ruby
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bundle exec rubocop
test:
name: Test
runs-on: macos-26
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install XcodeGen
run: brew install xcodegen
- run: bundle exec fastlane ios test
build-ios-debug:
name: Build iOS/iPadOS Debug
runs-on: macos-26
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install XcodeGen
run: brew install xcodegen
- run: bundle exec fastlane ios build_debug
build-ios-release:
name: Build iOS/iPadOS Release
runs-on: macos-26
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install XcodeGen
run: brew install xcodegen
- run: bundle exec fastlane ios build_release
build-macos-debug:
name: Build macOS Debug
runs-on: macos-26
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install XcodeGen
run: brew install xcodegen
- run: bundle exec fastlane mac build_debug
build-macos-release:
name: Build macOS Release
runs-on: macos-26
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install XcodeGen
run: brew install xcodegen
- run: bundle exec fastlane mac build_release
================================================
FILE: .github/workflows/push_on_master.yml
================================================
name: Push on master
on:
push:
branches:
- master
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
bump-version:
name: Bump version
runs-on: macos-26
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bundle exec fastlane bump_version
distribute-ios:
name: Distribute iOS/iPadOS
environment: release
needs: bump-version
runs-on: macos-26
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install XcodeGen
run: brew install xcodegen
- env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
run: bundle exec fastlane ios distribute
distribute-macos:
name: Distribute macOS
environment: release
needs: bump-version
runs-on: macos-26
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install XcodeGen
run: brew install xcodegen
- env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
run: bundle exec fastlane mac distribute
================================================
FILE: .gitignore
================================================
Jottre.xcodeproj
Jottre.entitlements
Resources/Info.plist
**/.DS_Store
================================================
FILE: .license_header_template
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) YEARS Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
================================================
FILE: .licenseignore
================================================
.github
README.md
.gitignore
*.md
*.json
*.yml
*.yaml
*.rb
*.jpg
Jottre.xcodeproj
Resources
Tests/Resources
hooks
fastlane
Gemfile
Gemfile.lock
Brewfile
LICENSE
.ruby-version
.xcode-version
================================================
FILE: .rubocop.yml
================================================
Layout/IndentationWidth:
Width: 4
plugins:
- rubocop-performance
AllCops:
NewCops: enable
TargetRubyVersion: 3.0
Exclude:
- 'vendor/**/*'
Metrics:
Enabled: false
Naming:
Enabled: false
Style:
Enabled: false
Lint:
Enabled: false
Security:
Enabled: true
Security/Open:
Enabled: false
Performance:
Enabled: false
================================================
FILE: .ruby-version
================================================
4.0.1
================================================
FILE: .swift-format
================================================
{
"version": 1,
"indentation": {
"spaces": 4
},
"tabWidth": 4,
"fileScopedDeclarationPrivacy": {
"accessLevel": "private"
},
"spacesAroundRangeFormationOperators": false,
"indentConditionalCompilationBlocks": false,
"indentSwitchCaseLabels": false,
"lineBreakAroundMultilineExpressionChainComponents": false,
"lineBreakBeforeControlFlowKeywords": false,
"lineBreakBeforeEachArgument": true,
"lineBreakBeforeEachGenericRequirement": true,
"lineLength": 120,
"maximumBlankLines": 1,
"respectsExistingLineBreaks": true,
"prioritizeKeepingFunctionOutputTogether": true,
"rules": {
"AllPublicDeclarationsHaveDocumentation": false,
"AlwaysUseLiteralForEmptyCollectionInit": false,
"AlwaysUseLowerCamelCase": false,
"AmbiguousTrailingClosureOverload": false,
"BeginDocumentationCommentWithOneLineSummary": false,
"DoNotUseSemicolons": true,
"DontRepeatTypeInStaticProperties": true,
"FileScopedDeclarationPrivacy": true,
"FullyIndirectEnum": true,
"GroupNumericLiterals": true,
"IdentifiersMustBeASCII": true,
"NeverForceUnwrap": true,
"NeverUseForceTry": true,
"NeverUseImplicitlyUnwrappedOptionals": false,
"NoAccessLevelOnExtensionDeclaration": true,
"NoAssignmentInExpressions": true,
"NoBlockComments": false,
"NoCasesWithOnlyFallthrough": true,
"NoEmptyTrailingClosureParentheses": true,
"NoLabelsInCasePatterns": true,
"NoLeadingUnderscores": false,
"NoParensAroundConditions": true,
"NoVoidReturnOnFunctionSignature": true,
"OmitExplicitReturns": true,
"OneCasePerLine": true,
"OneVariableDeclarationPerLine": true,
"OnlyOneTrailingClosureArgument": true,
"OrderedImports": true,
"ReplaceForEachWithForLoop": true,
"ReturnVoidInsteadOfEmptyTuple": true,
"UseEarlyExits": true,
"UseExplicitNilCheckInConditions": true,
"UseLetInEveryBoundCaseVariable": false,
"UseShorthandTypeNames": true,
"UseSingleLinePropertyGetter": true,
"UseSynthesizedInitializer": false,
"UseTripleSlashForDocumentationComments": true,
"UseWhereClausesInForLoops": true,
"ValidateDocumentationComments": false
}
}
================================================
FILE: .xcode-version
================================================
26.4.1
================================================
FILE: AppKitPlugin/AppKitPlugin.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import AppKit
@objc(AppKitPlugin)
public final class AppKitPlugin: NSObject {
@MainActor
@objc
public static func terminate() {
NSApplication.shared.terminate(nil)
}
}
================================================
FILE: AppKitPlugin/Info.plist
================================================
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleExecutable
AppKitPlugin
CFBundleIdentifier
com.antonlorani.jottre.AppKitPlugin
CFBundleInfoDictionaryVersion
6.0
CFBundleName
AppKitPlugin
CFBundlePackageType
BNDL
CFBundleShortVersionString
1
CFBundleVersion
1
================================================
FILE: Brewfile
================================================
# frozen_string_literal: true
brew 'rbenv'
brew 'xcodegen'
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Code of Conduct
## Pledge
Participation in this project should be welcoming, respectful, and harassment-free for everyone, regardless of age, body size, disability, ethnicity, gender identity, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Expected Behavior
Examples of behavior that contributes to a positive environment include:
- Being respectful and inclusive
- Accepting constructive feedback gracefully
- Showing empathy toward other community members
- Focusing on collaboration and learning
- Using welcoming and professional language
## Unacceptable Behavior
Examples of unacceptable behavior include:
- Harassment, discrimination, or hateful conduct
- Trolling, insulting, or derogatory comments
- Personal or political attacks
- Publishing private information without permission
- Any conduct that could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing standards of acceptable behavior. Maintainers may remove, edit, or reject comments, commits, issues, and other contributions that violate this Code of Conduct.
## Scope
This Code of Conduct applies within all project spaces, including:
- GitHub issues and pull requests
- Discussions and community forums
- Chat platforms and social media related to the project
- Public or private interactions representing the project
## Reporting
If unacceptable behavior is experienced or witnessed, report it to the project maintainers through the repository’s contact channels.
All reports will be reviewed and investigated promptly and fairly.
## Enforcement
Project maintainers may take any action deemed appropriate, including:
- Warning the offender
- Temporary suspension
- Permanent ban from the community
## Attribution
Inspired by the Contributor Covenant, version 2.1.
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing Guidelines
Pull requests, bug reports, and all other forms of contributions to make Jottre even better are welcomed and highly encouraged!
## Installation
Contributions occur on the premise that getting the project working with little effort. Initially there are a few tools to install that make recurring contributions much simpler.
See [README.md](README.md#installation) for installation details.
## Creating an issue
Take a look at the [existing issues](https://github.com/antonlorani/jottre/issues), maybe your concern is already addressed. If not, use the provided issue templates for [feature requests](https://github.com/antonlorani/jottre/issues/new?template=feature_request.yml) or [bug reports](https://github.com/antonlorani/jottre/issues/new?template=bug_report.yml).
> [!IMPORTANT]
> For security related issues, see [Security Policy](SECURITY_POLICY.md) first!
## Working on an issue
Any form of active contribution requires an associated Github issue. See [Creating an issue](#creating-an-issue).
Issues that are in the *todo* status and have no assignee yet can be freely taken. If no contribution occurs for the issue, the issue assignee MAY be reset.
Do ONLY work on things that are part of the issue's scope.
Create your working branch by branching from `master`, ensure that the working branch is prefixed with the issue-key.
```
42-zoom-state-not-restored
```
Commit messages SHOULD be suffixed the issue keys as well.
```
Adds zoom scale to scene restoration (#42)
```
Other than making sure that the commit messages communicates the intent clearly, no specific guidelines exist onto how to write a commit message.
Before raising a pull request, run the [test verification](README.md#validation):
```
bundle exec fastlane test
```
## Finalizing your contribution
### Certificate of origin
BY OPENING A PULL REQUEST, YOU CERTIFY THAT:
1. THE CONTRIBUTION WAS WRITTEN IN WHOLE OR IN PART BY YOU, AND YOU HAVE THE RIGHT TO SUBMIT IT UNDER THE [PROJECT'S LICENSE](LICENSE).
2. THE CONTRIBUTION IS BASED ON PREVIOUS WORK THAT, TO THE BEST OF YOUR KNOWLEDGE, IS COVERED UNDER AN APPROPRIATE OPEN SOURCE LICENSE - AND YOU HAVE THE RIGHT TO SUBMIT IT WITH MODIFICATIONS UNDER THE SAME LICENSE.
3. THE CONTRIBUTION WAS PROVIDED DIRECTLY TO YOU BY SOME OTHER PERSON WHO CERTIFIED (1) OR (2), AND YOU HAVE NOT MODIFIED IT.
### Raise a pull request
Once your work is ready, raise a pull request. Every pull request undergoes a formal code review. Code reviews fall under the [Code of Conduct](CODE_OF_CONDUCT.md).
Once your pull request is approved, the owner of this project takes care of merging your contribution into upstream `master` and schedule the changes into the next app store release.
## Policy on coding agents
Contributions via coding agents are accepted and expected. Your coding agent SHOULD adopt the projects conventions and retain the projects code quality. It's the operators responsibility to align their coding agent with those.
================================================
FILE: Gemfile
================================================
# frozen_string_literal: true
source 'https://rubygems.org'
gem 'fastlane'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: PRIVACY_POLICY.md
================================================
# Privacy Policy
_Last updated: 2026-05-01_
Jottre ("the app") is designed to keep your notes private. This policy explains what data the app handles and how.
## Data the app collects
**None.** Jottre does not collect, transmit, or share any personal data with the developer or any third party. There are no analytics, no tracking, no advertising SDKs, and no developer-operated servers.
## Data stored on your device
Jottre stores the notes and sketches you create as files on your device. These files never leave your device except through the mechanisms you choose (see below).
## iCloud sync
If you enable iCloud sync, your notes are stored in your personal iCloud account and synchronized across your Apple devices by Apple. The developer has no access to this data. iCloud's handling of your data is governed by [Apple's Privacy Policy](https://www.apple.com/legal/privacy/).
## Sharing and export
When you export a note as PDF, JPG, or PNG, or share it through the iOS share sheet, the resulting file is handled by the app or service you choose. Jottre itself does not transmit the file anywhere.
## Permissions
Jottre may request access to:
- **Files and folders** you select, so it can open and save notes.
- **iCloud Drive**, if you choose to sync notes across devices.
No other permissions are requested.
## Changes to this policy
If this policy changes, the updated version will be published in this repository with a new "Last updated" date.
## Contact
Questions or concerns: [open an issue](https://github.com/antonlorani/jottre/issues).
================================================
FILE: README.md
================================================
# Jottre
Simple and minimalistic handwriting app across Apple platforms.
**Available on the [App Store](https://apps.apple.com/us/app/jottre/id1550272319)**

## Contributing
Contributions are welcome. Before opening an issue or pull request, please review the following documents:
- [Contributing guidelines](CONTRIBUTING.md)
- [Code of conduct](CODE_OF_CONDUCT.md)
- [Security policy](SECURITY_POLICY.md)
## Development
### Installation
Configure the Ruby toolchain with [rbenv](https://github.com/rbenv/rbenv):
```sh
curl -fsSL https://github.com/rbenv/rbenv-installer/raw/HEAD/bin/rbenv-installer | bash
rbenv install $(cat .ruby-version)
rbenv local $(cat .ruby-version)
```
Install Bundler and the project's Ruby dependencies:
```sh
gem install bundler
bundle install
```
Install Xcode at the pinned version using [xcodes](https://github.com/XcodesOrg/xcodes):
```sh
brew install xcodesorg/made/xcodes
brew install aria2
xcodes install $(cat .xcode-version) --experimental-unxip
xcodes select $(cat .xcode-version)
```
Generate the Xcode project:
```sh
bundle exec fastlane generate_project
```
### Validation
Verify the setup by running the test suite:
```sh
bundle exec fastlane test
```
## License
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
================================================
FILE: Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
================================================
{
"images" : [
{
"filename" : "icon_20x20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon_60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon_60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon_20x20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_29x29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_40x40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_76x76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon_76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon_83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "icon_1024x1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: Resources/Localizable.xcstrings
================================================
{
"sourceLanguage": "en",
"strings": {
"action.cancel": {
"comment": "Generic cancel action label",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Cancel" } },
"de": { "stringUnit": { "state": "translated", "value": "Abbrechen" } },
"af": { "stringUnit": { "state": "translated", "value": "Kanselleer" } },
"ar": { "stringUnit": { "state": "translated", "value": "إلغاء" } },
"es": { "stringUnit": { "state": "translated", "value": "Cancelar" } },
"fr": { "stringUnit": { "state": "translated", "value": "Annuler" } },
"hi": { "stringUnit": { "state": "translated", "value": "रद्द करें" } },
"id": { "stringUnit": { "state": "translated", "value": "Batal" } },
"it": { "stringUnit": { "state": "translated", "value": "Annulla" } },
"ja": { "stringUnit": { "state": "translated", "value": "キャンセル" } },
"ko": { "stringUnit": { "state": "translated", "value": "취소" } },
"ms": { "stringUnit": { "state": "translated", "value": "Batal" } },
"nl": { "stringUnit": { "state": "translated", "value": "Annuleren" } },
"pl": { "stringUnit": { "state": "translated", "value": "Anuluj" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Cancelar" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Cancelar" } },
"ru": { "stringUnit": { "state": "translated", "value": "Отмена" } },
"sv": { "stringUnit": { "state": "translated", "value": "Avbryt" } },
"th": { "stringUnit": { "state": "translated", "value": "ยกเลิก" } },
"tr": { "stringUnit": { "state": "translated", "value": "İptal" } },
"uk": { "stringUnit": { "state": "translated", "value": "Скасувати" } },
"vi": { "stringUnit": { "state": "translated", "value": "Hủy" } }
}
},
"action.create": {
"comment": "Generic create action label",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Create" } },
"de": { "stringUnit": { "state": "translated", "value": "Erstellen" } },
"af": { "stringUnit": { "state": "translated", "value": "Skep" } },
"ar": { "stringUnit": { "state": "translated", "value": "إنشاء" } },
"es": { "stringUnit": { "state": "translated", "value": "Crear" } },
"fr": { "stringUnit": { "state": "translated", "value": "Créer" } },
"hi": { "stringUnit": { "state": "translated", "value": "बनाएं" } },
"id": { "stringUnit": { "state": "translated", "value": "Buat" } },
"it": { "stringUnit": { "state": "translated", "value": "Crea" } },
"ja": { "stringUnit": { "state": "translated", "value": "作成" } },
"ko": { "stringUnit": { "state": "translated", "value": "만들기" } },
"ms": { "stringUnit": { "state": "translated", "value": "Cipta" } },
"nl": { "stringUnit": { "state": "translated", "value": "Aanmaken" } },
"pl": { "stringUnit": { "state": "translated", "value": "Utwórz" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Criar" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Criar" } },
"ru": { "stringUnit": { "state": "translated", "value": "Создать" } },
"sv": { "stringUnit": { "state": "translated", "value": "Skapa" } },
"th": { "stringUnit": { "state": "translated", "value": "สร้าง" } },
"tr": { "stringUnit": { "state": "translated", "value": "Oluştur" } },
"uk": { "stringUnit": { "state": "translated", "value": "Створити" } },
"vi": { "stringUnit": { "state": "translated", "value": "Tạo" } }
}
},
"action.delete": {
"comment": "Generic delete action label",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Delete" } },
"de": { "stringUnit": { "state": "translated", "value": "Löschen" } },
"af": { "stringUnit": { "state": "translated", "value": "Verwyder" } },
"ar": { "stringUnit": { "state": "translated", "value": "حذف" } },
"es": { "stringUnit": { "state": "translated", "value": "Eliminar" } },
"fr": { "stringUnit": { "state": "translated", "value": "Supprimer" } },
"hi": { "stringUnit": { "state": "translated", "value": "हटाएं" } },
"id": { "stringUnit": { "state": "translated", "value": "Hapus" } },
"it": { "stringUnit": { "state": "translated", "value": "Elimina" } },
"ja": { "stringUnit": { "state": "translated", "value": "削除" } },
"ko": { "stringUnit": { "state": "translated", "value": "삭제" } },
"ms": { "stringUnit": { "state": "translated", "value": "Padam" } },
"nl": { "stringUnit": { "state": "translated", "value": "Verwijderen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Usuń" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Excluir" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Eliminar" } },
"ru": { "stringUnit": { "state": "translated", "value": "Удалить" } },
"sv": { "stringUnit": { "state": "translated", "value": "Radera" } },
"th": { "stringUnit": { "state": "translated", "value": "ลบ" } },
"tr": { "stringUnit": { "state": "translated", "value": "Sil" } },
"uk": { "stringUnit": { "state": "translated", "value": "Видалити" } },
"vi": { "stringUnit": { "state": "translated", "value": "Xóa" } }
}
},
"action.done": {
"comment": "Generic done/confirm action label",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Done" } },
"de": { "stringUnit": { "state": "translated", "value": "Fertig" } },
"af": { "stringUnit": { "state": "translated", "value": "Klaar" } },
"ar": { "stringUnit": { "state": "translated", "value": "تم" } },
"es": { "stringUnit": { "state": "translated", "value": "Listo" } },
"fr": { "stringUnit": { "state": "translated", "value": "Terminé" } },
"hi": { "stringUnit": { "state": "translated", "value": "हो गया" } },
"id": { "stringUnit": { "state": "translated", "value": "Selesai" } },
"it": { "stringUnit": { "state": "translated", "value": "Fine" } },
"ja": { "stringUnit": { "state": "translated", "value": "完了" } },
"ko": { "stringUnit": { "state": "translated", "value": "완료" } },
"ms": { "stringUnit": { "state": "translated", "value": "Selesai" } },
"nl": { "stringUnit": { "state": "translated", "value": "Gereed" } },
"pl": { "stringUnit": { "state": "translated", "value": "Gotowe" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Concluído" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Concluído" } },
"ru": { "stringUnit": { "state": "translated", "value": "Готово" } },
"sv": { "stringUnit": { "state": "translated", "value": "Klar" } },
"th": { "stringUnit": { "state": "translated", "value": "เสร็จสิ้น" } },
"tr": { "stringUnit": { "state": "translated", "value": "Bitti" } },
"uk": { "stringUnit": { "state": "translated", "value": "Готово" } },
"vi": { "stringUnit": { "state": "translated", "value": "Xong" } }
}
},
"action.duplicate": {
"comment": "Generic duplicate action label",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Duplicate" } },
"de": { "stringUnit": { "state": "translated", "value": "Duplizieren" } },
"af": { "stringUnit": { "state": "translated", "value": "Dupliseer" } },
"ar": { "stringUnit": { "state": "translated", "value": "تكرار" } },
"es": { "stringUnit": { "state": "translated", "value": "Duplicar" } },
"fr": { "stringUnit": { "state": "translated", "value": "Dupliquer" } },
"hi": { "stringUnit": { "state": "translated", "value": "डुप्लीकेट करें" } },
"id": { "stringUnit": { "state": "translated", "value": "Duplikat" } },
"it": { "stringUnit": { "state": "translated", "value": "Duplica" } },
"ja": { "stringUnit": { "state": "translated", "value": "複製" } },
"ko": { "stringUnit": { "state": "translated", "value": "복제" } },
"ms": { "stringUnit": { "state": "translated", "value": "Duplikasi" } },
"nl": { "stringUnit": { "state": "translated", "value": "Dupliceren" } },
"pl": { "stringUnit": { "state": "translated", "value": "Duplikuj" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Duplicar" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Duplicar" } },
"ru": { "stringUnit": { "state": "translated", "value": "Дублировать" } },
"sv": { "stringUnit": { "state": "translated", "value": "Duplicera" } },
"th": { "stringUnit": { "state": "translated", "value": "ทำซ้ำ" } },
"tr": { "stringUnit": { "state": "translated", "value": "Çoğalt" } },
"uk": { "stringUnit": { "state": "translated", "value": "Дублювати" } },
"vi": { "stringUnit": { "state": "translated", "value": "Nhân đôi" } }
}
},
"action.ok": {
"comment": "Generic ok/acknowledge action label",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "OK" } },
"de": { "stringUnit": { "state": "translated", "value": "OK" } },
"af": { "stringUnit": { "state": "translated", "value": "OK" } },
"ar": { "stringUnit": { "state": "translated", "value": "حسنًا" } },
"es": { "stringUnit": { "state": "translated", "value": "OK" } },
"fr": { "stringUnit": { "state": "translated", "value": "OK" } },
"hi": { "stringUnit": { "state": "translated", "value": "ठीक है" } },
"id": { "stringUnit": { "state": "translated", "value": "OK" } },
"it": { "stringUnit": { "state": "translated", "value": "OK" } },
"ja": { "stringUnit": { "state": "translated", "value": "OK" } },
"ko": { "stringUnit": { "state": "translated", "value": "확인" } },
"ms": { "stringUnit": { "state": "translated", "value": "OK" } },
"nl": { "stringUnit": { "state": "translated", "value": "OK" } },
"pl": { "stringUnit": { "state": "translated", "value": "OK" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "OK" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "OK" } },
"ru": { "stringUnit": { "state": "translated", "value": "ОК" } },
"sv": { "stringUnit": { "state": "translated", "value": "OK" } },
"th": { "stringUnit": { "state": "translated", "value": "ตกลง" } },
"tr": { "stringUnit": { "state": "translated", "value": "Tamam" } },
"uk": { "stringUnit": { "state": "translated", "value": "OK" } },
"vi": { "stringUnit": { "state": "translated", "value": "OK" } }
}
},
"action.rename": {
"comment": "Generic rename action label",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Rename" } },
"de": { "stringUnit": { "state": "translated", "value": "Umbenennen" } },
"af": { "stringUnit": { "state": "translated", "value": "Hernoem" } },
"ar": { "stringUnit": { "state": "translated", "value": "إعادة تسمية" } },
"es": { "stringUnit": { "state": "translated", "value": "Renombrar" } },
"fr": { "stringUnit": { "state": "translated", "value": "Renommer" } },
"hi": { "stringUnit": { "state": "translated", "value": "नाम बदलें" } },
"id": { "stringUnit": { "state": "translated", "value": "Ganti Nama" } },
"it": { "stringUnit": { "state": "translated", "value": "Rinomina" } },
"ja": { "stringUnit": { "state": "translated", "value": "名前を変更" } },
"ko": { "stringUnit": { "state": "translated", "value": "이름 변경" } },
"ms": { "stringUnit": { "state": "translated", "value": "Namakan Semula" } },
"nl": { "stringUnit": { "state": "translated", "value": "Hernoemen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Zmień nazwę" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Renomear" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Renomear" } },
"ru": { "stringUnit": { "state": "translated", "value": "Переименовать" } },
"sv": { "stringUnit": { "state": "translated", "value": "Byt namn" } },
"th": { "stringUnit": { "state": "translated", "value": "เปลี่ยนชื่อ" } },
"tr": { "stringUnit": { "state": "translated", "value": "Yeniden Adlandır" } },
"uk": { "stringUnit": { "state": "translated", "value": "Перейменувати" } },
"vi": { "stringUnit": { "state": "translated", "value": "Đổi tên" } }
}
},
"action.share": {
"comment": "Generic share action label",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Share" } },
"de": { "stringUnit": { "state": "translated", "value": "Teilen" } },
"af": { "stringUnit": { "state": "translated", "value": "Deel" } },
"ar": { "stringUnit": { "state": "translated", "value": "مشاركة" } },
"es": { "stringUnit": { "state": "translated", "value": "Compartir" } },
"fr": { "stringUnit": { "state": "translated", "value": "Partager" } },
"hi": { "stringUnit": { "state": "translated", "value": "साझा करें" } },
"id": { "stringUnit": { "state": "translated", "value": "Bagikan" } },
"it": { "stringUnit": { "state": "translated", "value": "Condividi" } },
"ja": { "stringUnit": { "state": "translated", "value": "共有" } },
"ko": { "stringUnit": { "state": "translated", "value": "공유" } },
"ms": { "stringUnit": { "state": "translated", "value": "Kongsi" } },
"nl": { "stringUnit": { "state": "translated", "value": "Delen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Udostępnij" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Compartilhar" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Partilhar" } },
"ru": { "stringUnit": { "state": "translated", "value": "Поделиться" } },
"sv": { "stringUnit": { "state": "translated", "value": "Dela" } },
"th": { "stringUnit": { "state": "translated", "value": "แชร์" } },
"tr": { "stringUnit": { "state": "translated", "value": "Paylaş" } },
"uk": { "stringUnit": { "state": "translated", "value": "Поділитися" } },
"vi": { "stringUnit": { "state": "translated", "value": "Chia sẻ" } }
}
},
"app.title": {
"comment": "Application name displayed in the navigation bar on iOS",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"de": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"af": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"ar": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"es": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"fr": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"hi": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"id": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"it": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"ja": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"ko": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"ms": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"nl": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"pl": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"ru": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"sv": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"th": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"tr": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"uk": { "stringUnit": { "state": "translated", "value": "Jottre" } },
"vi": { "stringUnit": { "state": "translated", "value": "Jottre" } }
}
},
"cloudMigration.errorAlert.title": {
"comment": "The title of the error alert when migrating a Jot between the local file-system and iCloud failed.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Unable to migrate \"%@\"" } },
"de": { "stringUnit": { "state": "translated", "value": "Migration von \"%@\" nicht möglich" } },
"af": { "stringUnit": { "state": "translated", "value": "Kan \"%@\" nie migreer nie" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعذّر ترحيل \"%@\"" } },
"es": { "stringUnit": { "state": "translated", "value": "No se puede migrar \"%@\"" } },
"fr": { "stringUnit": { "state": "translated", "value": "Impossible de migrer \"%@\"" } },
"hi": { "stringUnit": { "state": "translated", "value": "\"%@\" को माइग्रेट नहीं किया जा सका" } },
"id": { "stringUnit": { "state": "translated", "value": "Tidak dapat memigrasikan \"%@\"" } },
"it": { "stringUnit": { "state": "translated", "value": "Impossibile migrare \"%@\"" } },
"ja": { "stringUnit": { "state": "translated", "value": "「%@」を移行できません" } },
"ko": { "stringUnit": { "state": "translated", "value": "\"%@\"을(를) 마이그레이션할 수 없음" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tidak dapat memigrasikan \"%@\"" } },
"nl": { "stringUnit": { "state": "translated", "value": "Kan \"%@\" niet migreren" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nie można zmigrować \"%@\"" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível migrar \"%@\"" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Não foi possível migrar \"%@\"" } },
"ru": { "stringUnit": { "state": "translated", "value": "Не удалось перенести «%@»" } },
"sv": { "stringUnit": { "state": "translated", "value": "Kan inte migrera \"%@\"" } },
"th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถย้าย \"%@\" ได้" } },
"tr": { "stringUnit": { "state": "translated", "value": "\"%@\" taşınamıyor" } },
"uk": { "stringUnit": { "state": "translated", "value": "Не вдалося перенести «%@»" } },
"vi": { "stringUnit": { "state": "translated", "value": "Không thể di chuyển \"%@\"" } }
}
},
"cloudMigration.nothingToMigrate.subtitle": {
"comment": "Subtitle on the iCloud migration screen when there are no Jots to migrate",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Newly created Jots are now automatically synchronized with iCloud and accessible across all your iCloud-compatible devices." } },
"de": { "stringUnit": { "state": "translated", "value": "Neu erstellte Jots werden jetzt automatisch mit iCloud synchronisiert und sind auf all deinen iCloud-kompatiblen Geräten verfügbar." } },
"af": { "stringUnit": { "state": "translated", "value": "Nuutgeskepte Jots word nou outomaties met iCloud gesinkroniseer en is op al jou iCloud-versoenbare toestelle beskikbaar." } },
"ar": { "stringUnit": { "state": "translated", "value": "يتم الآن مزامنة Jots المنشأة حديثًا تلقائيًا مع iCloud ويمكن الوصول إليها عبر جميع أجهزتك المتوافقة مع iCloud." } },
"es": { "stringUnit": { "state": "translated", "value": "Los Jots recién creados ahora se sincronizan automáticamente con iCloud y son accesibles en todos tus dispositivos compatibles con iCloud." } },
"fr": { "stringUnit": { "state": "translated", "value": "Les Jots nouvellement créés sont désormais automatiquement synchronisés avec iCloud et accessibles sur tous vos appareils compatibles iCloud." } },
"hi": { "stringUnit": { "state": "translated", "value": "नए बनाए गए Jots अब स्वचालित रूप से iCloud के साथ सिंक्रनाइज़ होते हैं और आपके सभी iCloud-संगत डिवाइसों पर उपलब्ध हैं।" } },
"id": { "stringUnit": { "state": "translated", "value": "Jots yang baru dibuat kini disinkronkan secara otomatis dengan iCloud dan dapat diakses di semua perangkat yang kompatibel dengan iCloud." } },
"it": { "stringUnit": { "state": "translated", "value": "I Jots appena creati vengono ora sincronizzati automaticamente con iCloud e sono accessibili su tutti i tuoi dispositivi compatibili con iCloud." } },
"ja": { "stringUnit": { "state": "translated", "value": "新しく作成されたJotは自動的にiCloudと同期され、すべてのiCloud対応デバイスからアクセスできます。" } },
"ko": { "stringUnit": { "state": "translated", "value": "새로 만든 Jot은 이제 iCloud와 자동으로 동기화되며 모든 iCloud 호환 기기에서 접근할 수 있습니다." } },
"ms": { "stringUnit": { "state": "translated", "value": "Jots yang baru dibuat kini disegerakkan secara automatik dengan iCloud dan boleh diakses pada semua peranti serasi iCloud anda." } },
"nl": { "stringUnit": { "state": "translated", "value": "Nieuw aangemaakte Jots worden nu automatisch gesynchroniseerd met iCloud en zijn toegankelijk op al je iCloud-compatibele apparaten." } },
"pl": { "stringUnit": { "state": "translated", "value": "Nowo utworzone Jots są teraz automatycznie synchronizowane z iCloud i dostępne na wszystkich urządzeniach kompatybilnych z iCloud." } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Os Jots recém-criados agora são sincronizados automaticamente com o iCloud e acessíveis em todos os seus dispositivos compatíveis com o iCloud." } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Os Jots recém-criados são agora automaticamente sincronizados com o iCloud e acessíveis em todos os seus dispositivos compatíveis com o iCloud." } },
"ru": { "stringUnit": { "state": "translated", "value": "Новые Jots теперь автоматически синхронизируются с iCloud и доступны на всех ваших устройствах, совместимых с iCloud." } },
"sv": { "stringUnit": { "state": "translated", "value": "Nyskapade Jots synkroniseras nu automatiskt med iCloud och är tillgängliga på alla dina iCloud-kompatibla enheter." } },
"th": { "stringUnit": { "state": "translated", "value": "Jots ที่สร้างใหม่จะซิงค์กับ iCloud โดยอัตโนมัติและเข้าถึงได้จากอุปกรณ์ที่รองรับ iCloud ทั้งหมดของคุณ" } },
"tr": { "stringUnit": { "state": "translated", "value": "Yeni oluşturulan Jots artık iCloud ile otomatik olarak senkronize ediliyor ve tüm iCloud uyumlu cihazlarınızdan erişilebilir." } },
"uk": { "stringUnit": { "state": "translated", "value": "Щойно створені Jots тепер автоматично синхронізуються з iCloud і доступні на всіх ваших пристроях, сумісних з iCloud." } },
"vi": { "stringUnit": { "state": "translated", "value": "Các Jots mới tạo giờ đây được tự động đồng bộ với iCloud và có thể truy cập trên tất cả các thiết bị tương thích iCloud của bạn." } }
}
},
"cloudMigration.subtitle": {
"comment": "Subtitle on the iCloud migration screen",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Your Jots can now sync across all your devices. Choose which ones to bring along." } },
"de": { "stringUnit": { "state": "translated", "value": "Deine Jots können jetzt auf all deinen Geräten synchronisiert werden. Wähle aus, welche du mitnehmen möchtest." } },
"af": { "stringUnit": { "state": "translated", "value": "Jou Jots kan nou oor al jou toestelle sinkroniseer. Kies watter jy wil saamneem." } },
"ar": { "stringUnit": { "state": "translated", "value": "يمكن الآن مزامنة Jots عبر جميع أجهزتك. اختر أيّها تريد نقله." } },
"es": { "stringUnit": { "state": "translated", "value": "Tus Jots ya pueden sincronizarse en todos tus dispositivos. Elige cuáles quieres llevar contigo." } },
"fr": { "stringUnit": { "state": "translated", "value": "Vos Jots peuvent désormais se synchroniser sur tous vos appareils. Choisissez lesquels emporter." } },
"hi": { "stringUnit": { "state": "translated", "value": "आपके Jots अब आपके सभी डिवाइस पर सिंक हो सकते हैं। चुनें कि कौन से लाने हैं।" } },
"id": { "stringUnit": { "state": "translated", "value": "Jots Anda kini dapat disinkronkan di semua perangkat. Pilih mana yang ingin dibawa." } },
"it": { "stringUnit": { "state": "translated", "value": "I tuoi Jots ora si sincronizzano su tutti i tuoi dispositivi. Scegli quali portare con te." } },
"ja": { "stringUnit": { "state": "translated", "value": "Jotがすべてのデバイスで同期できるようになりました。持ち込むものを選んでください。" } },
"ko": { "stringUnit": { "state": "translated", "value": "이제 모든 기기에서 Jot을 동기화할 수 있습니다. 가져올 항목을 선택하세요." } },
"ms": { "stringUnit": { "state": "translated", "value": "Jots anda kini boleh disegerakkan di semua peranti. Pilih yang mana hendak dibawa." } },
"nl": { "stringUnit": { "state": "translated", "value": "Je Jots kunnen nu synchroniseren op al je apparaten. Kies welke je wilt meenemen." } },
"pl": { "stringUnit": { "state": "translated", "value": "Twoje Jots mogą teraz synchronizować się na wszystkich urządzeniach. Wybierz, które chcesz przenieść." } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Seus Jots agora podem sincronizar em todos os seus dispositivos. Escolha quais deseja trazer." } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Os seus Jots podem agora sincronizar em todos os seus dispositivos. Escolha quais pretende trazer." } },
"ru": { "stringUnit": { "state": "translated", "value": "Теперь ваши Jots можно синхронизировать на всех устройствах. Выберите, какие перенести." } },
"sv": { "stringUnit": { "state": "translated", "value": "Dina Jots kan nu synkroniseras på alla dina enheter. Välj vilka du vill ta med." } },
"th": { "stringUnit": { "state": "translated", "value": "Jots ของคุณสามารถซิงค์ข้ามอุปกรณ์ทั้งหมดได้แล้ว เลือกรายการที่ต้องการนำมาด้วย" } },
"tr": { "stringUnit": { "state": "translated", "value": "Jots'larınız artık tüm cihazlarınızda eşitlenebilir. Hangilerini getireceğinizi seçin." } },
"uk": { "stringUnit": { "state": "translated", "value": "Тепер ваші Jots можна синхронізувати на всіх пристроях. Виберіть, які перенести." } },
"vi": { "stringUnit": { "state": "translated", "value": "Jots của bạn giờ có thể đồng bộ trên tất cả thiết bị. Hãy chọn những mục muốn mang theo." } }
}
},
"cloudMigration.title": {
"comment": "Headline on the iCloud migration screen",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "iCloud is ready" } },
"de": { "stringUnit": { "state": "translated", "value": "iCloud ist bereit" } },
"af": { "stringUnit": { "state": "translated", "value": "iCloud is gereed" } },
"ar": { "stringUnit": { "state": "translated", "value": "iCloud جاهز" } },
"es": { "stringUnit": { "state": "translated", "value": "iCloud está listo" } },
"fr": { "stringUnit": { "state": "translated", "value": "iCloud est prêt" } },
"hi": { "stringUnit": { "state": "translated", "value": "iCloud तैयार है" } },
"id": { "stringUnit": { "state": "translated", "value": "iCloud siap" } },
"it": { "stringUnit": { "state": "translated", "value": "iCloud è pronto" } },
"ja": { "stringUnit": { "state": "translated", "value": "iCloudの準備ができました" } },
"ko": { "stringUnit": { "state": "translated", "value": "iCloud 준비 완료" } },
"ms": { "stringUnit": { "state": "translated", "value": "iCloud sedia" } },
"nl": { "stringUnit": { "state": "translated", "value": "iCloud is klaar" } },
"pl": { "stringUnit": { "state": "translated", "value": "iCloud jest gotowy" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "iCloud está pronto" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "iCloud está pronto" } },
"ru": { "stringUnit": { "state": "translated", "value": "iCloud готов" } },
"sv": { "stringUnit": { "state": "translated", "value": "iCloud är redo" } },
"th": { "stringUnit": { "state": "translated", "value": "iCloud พร้อมแล้ว" } },
"tr": { "stringUnit": { "state": "translated", "value": "iCloud hazır" } },
"uk": { "stringUnit": { "state": "translated", "value": "iCloud готовий" } },
"vi": { "stringUnit": { "state": "translated", "value": "iCloud đã sẵn sàng" } }
}
},
"enableCloud.action.learnHowToEnable": {
"comment": "Call-to-action button on the Enable iCloud screen",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Learn How To Enable" } },
"de": { "stringUnit": { "state": "translated", "value": "So aktivierst du iCloud" } },
"af": { "stringUnit": { "state": "translated", "value": "Leer hoe om te aktiveer" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعرّف على كيفية التفعيل" } },
"es": { "stringUnit": { "state": "translated", "value": "Aprende cómo activarlo" } },
"fr": { "stringUnit": { "state": "translated", "value": "Apprendre comment activer" } },
"hi": { "stringUnit": { "state": "translated", "value": "सक्षम करना सीखें" } },
"id": { "stringUnit": { "state": "translated", "value": "Pelajari Cara Mengaktifkan" } },
"it": { "stringUnit": { "state": "translated", "value": "Scopri come abilitarlo" } },
"ja": { "stringUnit": { "state": "translated", "value": "有効にする方法を見る" } },
"ko": { "stringUnit": { "state": "translated", "value": "활성화 방법 알아보기" } },
"ms": { "stringUnit": { "state": "translated", "value": "Ketahui Cara Mengaktifkan" } },
"nl": { "stringUnit": { "state": "translated", "value": "Leer hoe je het inschakelt" } },
"pl": { "stringUnit": { "state": "translated", "value": "Dowiedz się, jak włączyć" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Saiba como ativar" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Saiba como ativar" } },
"ru": { "stringUnit": { "state": "translated", "value": "Как включить" } },
"sv": { "stringUnit": { "state": "translated", "value": "Lär dig hur du aktiverar" } },
"th": { "stringUnit": { "state": "translated", "value": "เรียนรู้วิธีเปิดใช้งาน" } },
"tr": { "stringUnit": { "state": "translated", "value": "Nasıl Etkinleştirileceğini Öğren" } },
"uk": { "stringUnit": { "state": "translated", "value": "Дізнатися, як увімкнути" } },
"vi": { "stringUnit": { "state": "translated", "value": "Tìm hiểu cách bật" } }
}
},
"enableCloud.feature.share": {
"comment": "Feature row describing the sharing capability on the Enable iCloud screen",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Share Jots with others" } },
"de": { "stringUnit": { "state": "translated", "value": "Jots mit anderen teilen" } },
"af": { "stringUnit": { "state": "translated", "value": "Deel Jots met ander mense" } },
"ar": { "stringUnit": { "state": "translated", "value": "مشاركة Jots مع الآخرين" } },
"es": { "stringUnit": { "state": "translated", "value": "Comparte Jots con otros" } },
"fr": { "stringUnit": { "state": "translated", "value": "Partagez des Jots avec d'autres" } },
"hi": { "stringUnit": { "state": "translated", "value": "Jots दूसरों के साथ साझा करें" } },
"id": { "stringUnit": { "state": "translated", "value": "Bagikan Jots dengan orang lain" } },
"it": { "stringUnit": { "state": "translated", "value": "Condividi Jots con altri" } },
"ja": { "stringUnit": { "state": "translated", "value": "Jotを他の人と共有" } },
"ko": { "stringUnit": { "state": "translated", "value": "다른 사람과 Jot 공유" } },
"ms": { "stringUnit": { "state": "translated", "value": "Kongsi Jots dengan orang lain" } },
"nl": { "stringUnit": { "state": "translated", "value": "Deel Jots met anderen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Udostępniaj Jots innym" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Compartilhe Jots com outras pessoas" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Partilhe Jots com outras pessoas" } },
"ru": { "stringUnit": { "state": "translated", "value": "Делитесь Jots с другими" } },
"sv": { "stringUnit": { "state": "translated", "value": "Dela Jots med andra" } },
"th": { "stringUnit": { "state": "translated", "value": "แชร์ Jots กับผู้อื่น" } },
"tr": { "stringUnit": { "state": "translated", "value": "Jots'ları başkalarıyla paylaş" } },
"uk": { "stringUnit": { "state": "translated", "value": "Діліться Jots з іншими" } },
"vi": { "stringUnit": { "state": "translated", "value": "Chia sẻ Jots với người khác" } }
}
},
"enableCloud.feature.sync": {
"comment": "Feature row describing cross-device sync on the Enable iCloud screen",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Synchronize Jots across all your Apple devices" } },
"de": { "stringUnit": { "state": "translated", "value": "Jots auf all deinen Apple-Geräten synchronisieren" } },
"af": { "stringUnit": { "state": "translated", "value": "Sinkroniseer Jots oor al jou Apple-toestelle" } },
"ar": { "stringUnit": { "state": "translated", "value": "مزامنة Jots عبر جميع أجهزة Apple الخاصة بك" } },
"es": { "stringUnit": { "state": "translated", "value": "Sincroniza Jots en todos tus dispositivos Apple" } },
"fr": { "stringUnit": { "state": "translated", "value": "Synchronisez les Jots sur tous vos appareils Apple" } },
"hi": { "stringUnit": { "state": "translated", "value": "अपने सभी Apple डिवाइस पर Jots सिंक करें" } },
"id": { "stringUnit": { "state": "translated", "value": "Sinkronkan Jots di semua perangkat Apple Anda" } },
"it": { "stringUnit": { "state": "translated", "value": "Sincronizza i Jots su tutti i tuoi dispositivi Apple" } },
"ja": { "stringUnit": { "state": "translated", "value": "すべてのAppleデバイスでJotを同期" } },
"ko": { "stringUnit": { "state": "translated", "value": "모든 Apple 기기에서 Jot 동기화" } },
"ms": { "stringUnit": { "state": "translated", "value": "Segerakkan Jots di semua peranti Apple anda" } },
"nl": { "stringUnit": { "state": "translated", "value": "Synchroniseer Jots op al je Apple-apparaten" } },
"pl": { "stringUnit": { "state": "translated", "value": "Synchronizuj Jots na wszystkich urządzeniach Apple" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Sincronize Jots em todos os seus dispositivos Apple" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Sincronize Jots em todos os seus dispositivos Apple" } },
"ru": { "stringUnit": { "state": "translated", "value": "Синхронизируйте Jots на всех устройствах Apple" } },
"sv": { "stringUnit": { "state": "translated", "value": "Synkronisera Jots på alla dina Apple-enheter" } },
"th": { "stringUnit": { "state": "translated", "value": "ซิงค์ Jots ข้ามอุปกรณ์ Apple ทั้งหมดของคุณ" } },
"tr": { "stringUnit": { "state": "translated", "value": "Jots'ları tüm Apple cihazlarında eşitle" } },
"uk": { "stringUnit": { "state": "translated", "value": "Синхронізуйте Jots на всіх пристроях Apple" } },
"vi": { "stringUnit": { "state": "translated", "value": "Đồng bộ Jots trên tất cả thiết bị Apple của bạn" } }
}
},
"enableCloud.subtitle": {
"comment": "Subtitle explaining why iCloud should be enabled",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "It looks like iCloud is disabled on this device. Turn on iCloud to get the most out of Jottre." } },
"de": { "stringUnit": { "state": "translated", "value": "iCloud scheint auf diesem Gerät deaktiviert zu sein. Aktiviere iCloud, um Jottre optimal zu nutzen." } },
"af": { "stringUnit": { "state": "translated", "value": "Dit lyk asof iCloud op hierdie toestel gedeaktiveer is. Skakel iCloud aan om die meeste uit Jottre te kry." } },
"ar": { "stringUnit": { "state": "translated", "value": "يبدو أن iCloud معطّل على هذا الجهاز. شغّل iCloud للاستفادة الكاملة من Jottre." } },
"es": { "stringUnit": { "state": "translated", "value": "Parece que iCloud está desactivado en este dispositivo. Activa iCloud para sacar el máximo partido a Jottre." } },
"fr": { "stringUnit": { "state": "translated", "value": "Il semble qu'iCloud soit désactivé sur cet appareil. Activez iCloud pour profiter pleinement de Jottre." } },
"hi": { "stringUnit": { "state": "translated", "value": "लगता है इस डिवाइस पर iCloud बंद है। Jottre का पूरा लाभ उठाने के लिए iCloud चालू करें।" } },
"id": { "stringUnit": { "state": "translated", "value": "Sepertinya iCloud dinonaktifkan di perangkat ini. Aktifkan iCloud untuk memaksimalkan Jottre." } },
"it": { "stringUnit": { "state": "translated", "value": "Sembra che iCloud sia disattivato su questo dispositivo. Attiva iCloud per sfruttare al meglio Jottre." } },
"ja": { "stringUnit": { "state": "translated", "value": "このデバイスではiCloudが無効になっているようです。Jottreを最大限に活用するにはiCloudをオンにしてください。" } },
"ko": { "stringUnit": { "state": "translated", "value": "이 기기에서 iCloud가 비활성화된 것 같습니다. Jottre를 최대한 활용하려면 iCloud를 켜세요." } },
"ms": { "stringUnit": { "state": "translated", "value": "Nampaknya iCloud dilumpuhkan pada peranti ini. Hidupkan iCloud untuk mendapatkan manfaat penuh daripada Jottre." } },
"nl": { "stringUnit": { "state": "translated", "value": "Het lijkt erop dat iCloud op dit apparaat is uitgeschakeld. Schakel iCloud in om het meeste uit Jottre te halen." } },
"pl": { "stringUnit": { "state": "translated", "value": "Wygląda na to, że iCloud jest wyłączony na tym urządzeniu. Włącz iCloud, aby w pełni korzystać z Jottre." } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Parece que o iCloud está desativado neste dispositivo. Ative o iCloud para aproveitar ao máximo o Jottre." } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Parece que o iCloud está desativado neste dispositivo. Ative o iCloud para tirar o máximo partido do Jottre." } },
"ru": { "stringUnit": { "state": "translated", "value": "Похоже, iCloud отключён на этом устройстве. Включите iCloud, чтобы использовать все возможности Jottre." } },
"sv": { "stringUnit": { "state": "translated", "value": "Det verkar som att iCloud är inaktiverat på den här enheten. Slå på iCloud för att få ut det mesta av Jottre." } },
"th": { "stringUnit": { "state": "translated", "value": "ดูเหมือนว่า iCloud จะถูกปิดใช้งานบนอุปกรณ์นี้ เปิด iCloud เพื่อใช้ Jottre ได้อย่างเต็มที่" } },
"tr": { "stringUnit": { "state": "translated", "value": "Bu cihazda iCloud'un devre dışı bırakıldığı görünüyor. Jottre'den en iyi şekilde yararlanmak için iCloud'u açın." } },
"uk": { "stringUnit": { "state": "translated", "value": "Схоже, iCloud вимкнено на цьому пристрої. Увімкніть iCloud, щоб отримати максимум від Jottre." } },
"vi": { "stringUnit": { "state": "translated", "value": "Có vẻ iCloud đang bị tắt trên thiết bị này. Hãy bật iCloud để tận dụng tối đa Jottre." } }
}
},
"enableCloud.title": {
"comment": "Headline on the Enable iCloud screen",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Enable iCloud" } },
"de": { "stringUnit": { "state": "translated", "value": "iCloud aktivieren" } },
"af": { "stringUnit": { "state": "translated", "value": "Aktiveer iCloud" } },
"ar": { "stringUnit": { "state": "translated", "value": "تفعيل iCloud" } },
"es": { "stringUnit": { "state": "translated", "value": "Activar iCloud" } },
"fr": { "stringUnit": { "state": "translated", "value": "Activer iCloud" } },
"hi": { "stringUnit": { "state": "translated", "value": "iCloud सक्षम करें" } },
"id": { "stringUnit": { "state": "translated", "value": "Aktifkan iCloud" } },
"it": { "stringUnit": { "state": "translated", "value": "Abilita iCloud" } },
"ja": { "stringUnit": { "state": "translated", "value": "iCloudを有効にする" } },
"ko": { "stringUnit": { "state": "translated", "value": "iCloud 활성화" } },
"ms": { "stringUnit": { "state": "translated", "value": "Aktifkan iCloud" } },
"nl": { "stringUnit": { "state": "translated", "value": "iCloud inschakelen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Włącz iCloud" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Ativar iCloud" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Ativar iCloud" } },
"ru": { "stringUnit": { "state": "translated", "value": "Включить iCloud" } },
"sv": { "stringUnit": { "state": "translated", "value": "Aktivera iCloud" } },
"th": { "stringUnit": { "state": "translated", "value": "เปิดใช้งาน iCloud" } },
"tr": { "stringUnit": { "state": "translated", "value": "iCloud'u Etkinleştir" } },
"uk": { "stringUnit": { "state": "translated", "value": "Увімкнути iCloud" } },
"vi": { "stringUnit": { "state": "translated", "value": "Bật iCloud" } }
}
},
"filesystem.duplicate.filename.multi": {
"comment": "The suffix added to a duplicated file name.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "%@ copy %d" } },
"de": { "stringUnit": { "state": "translated", "value": "%@ Kopie %d" } },
"af": { "stringUnit": { "state": "translated", "value": "%@ kopie %d" } },
"ar": { "stringUnit": { "state": "translated", "value": "%@ نسخة %d" } },
"es": { "stringUnit": { "state": "translated", "value": "%@ copia %d" } },
"fr": { "stringUnit": { "state": "translated", "value": "%@ copie %d" } },
"hi": { "stringUnit": { "state": "translated", "value": "%@ प्रति %d" } },
"id": { "stringUnit": { "state": "translated", "value": "%@ salinan %d" } },
"it": { "stringUnit": { "state": "translated", "value": "%@ copia %d" } },
"ja": { "stringUnit": { "state": "translated", "value": "%@ のコピー %d" } },
"ko": { "stringUnit": { "state": "translated", "value": "%@ 사본 %d" } },
"ms": { "stringUnit": { "state": "translated", "value": "%@ salinan %d" } },
"nl": { "stringUnit": { "state": "translated", "value": "%@ kopie %d" } },
"pl": { "stringUnit": { "state": "translated", "value": "%@ kopia %d" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "%@ cópia %d" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "%@ cópia %d" } },
"ru": { "stringUnit": { "state": "translated", "value": "%@ копия %d" } },
"sv": { "stringUnit": { "state": "translated", "value": "%@ kopia %d" } },
"th": { "stringUnit": { "state": "translated", "value": "%@ สำเนา %d" } },
"tr": { "stringUnit": { "state": "translated", "value": "%@ kopya %d" } },
"uk": { "stringUnit": { "state": "translated", "value": "%@ копія %d" } },
"vi": { "stringUnit": { "state": "translated", "value": "%@ bản sao %d" } }
}
},
"filesystem.duplicate.filename.plain": {
"comment": "A suffix added to a duplicated file name with a number in case of name conflicts.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "%@ copy" } },
"de": { "stringUnit": { "state": "translated", "value": "%@ Kopie" } },
"af": { "stringUnit": { "state": "translated", "value": "%@ kopie" } },
"ar": { "stringUnit": { "state": "translated", "value": "%@ نسخة" } },
"es": { "stringUnit": { "state": "translated", "value": "%@ copia" } },
"fr": { "stringUnit": { "state": "translated", "value": "%@ copie" } },
"hi": { "stringUnit": { "state": "translated", "value": "%@ प्रति" } },
"id": { "stringUnit": { "state": "translated", "value": "%@ salinan" } },
"it": { "stringUnit": { "state": "translated", "value": "%@ copia" } },
"ja": { "stringUnit": { "state": "translated", "value": "%@ のコピー" } },
"ko": { "stringUnit": { "state": "translated", "value": "%@ 사본" } },
"ms": { "stringUnit": { "state": "translated", "value": "%@ salinan" } },
"nl": { "stringUnit": { "state": "translated", "value": "%@ kopie" } },
"pl": { "stringUnit": { "state": "translated", "value": "%@ kopia" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "%@ cópia" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "%@ cópia" } },
"ru": { "stringUnit": { "state": "translated", "value": "%@ копия" } },
"sv": { "stringUnit": { "state": "translated", "value": "%@ kopia" } },
"th": { "stringUnit": { "state": "translated", "value": "%@ สำเนา" } },
"tr": { "stringUnit": { "state": "translated", "value": "%@ kopya" } },
"uk": { "stringUnit": { "state": "translated", "value": "%@ копія" } },
"vi": { "stringUnit": { "state": "translated", "value": "%@ bản sao" } }
}
},
"jotConflict.action.keepAll": {
"comment": "Action to keep all conflicting versions of a Jot",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Keep All" } },
"de": { "stringUnit": { "state": "translated", "value": "Alle behalten" } },
"af": { "stringUnit": { "state": "translated", "value": "Hou almal" } },
"ar": { "stringUnit": { "state": "translated", "value": "احتفظ بالكل" } },
"es": { "stringUnit": { "state": "translated", "value": "Conservar todos" } },
"fr": { "stringUnit": { "state": "translated", "value": "Tout conserver" } },
"hi": { "stringUnit": { "state": "translated", "value": "सभी रखें" } },
"id": { "stringUnit": { "state": "translated", "value": "Simpan Semua" } },
"it": { "stringUnit": { "state": "translated", "value": "Tieni tutto" } },
"ja": { "stringUnit": { "state": "translated", "value": "すべて保持" } },
"ko": { "stringUnit": { "state": "translated", "value": "모두 유지" } },
"ms": { "stringUnit": { "state": "translated", "value": "Simpan Semua" } },
"nl": { "stringUnit": { "state": "translated", "value": "Alles bewaren" } },
"pl": { "stringUnit": { "state": "translated", "value": "Zachowaj wszystko" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Manter todos" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Manter todos" } },
"ru": { "stringUnit": { "state": "translated", "value": "Оставить все" } },
"sv": { "stringUnit": { "state": "translated", "value": "Behåll alla" } },
"th": { "stringUnit": { "state": "translated", "value": "เก็บทั้งหมด" } },
"tr": { "stringUnit": { "state": "translated", "value": "Tümünü Tut" } },
"uk": { "stringUnit": { "state": "translated", "value": "Зберегти всі" } },
"vi": { "stringUnit": { "state": "translated", "value": "Giữ tất cả" } }
}
},
"jotConflict.action.keepVersion": {
"comment": "Action to keep version A of a conflicting jot",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Keep Version %@" } },
"de": { "stringUnit": { "state": "translated", "value": "Version %@ behalten" } },
"af": { "stringUnit": { "state": "translated", "value": "Hou weergawe %@" } },
"ar": { "stringUnit": { "state": "translated", "value": "احتفظ بالإصدار %@" } },
"es": { "stringUnit": { "state": "translated", "value": "Conservar versión %@" } },
"fr": { "stringUnit": { "state": "translated", "value": "Conserver la version %@" } },
"hi": { "stringUnit": { "state": "translated", "value": "संस्करण %@ रखें" } },
"id": { "stringUnit": { "state": "translated", "value": "Simpan Versi %@" } },
"it": { "stringUnit": { "state": "translated", "value": "Tieni la versione %@" } },
"ja": { "stringUnit": { "state": "translated", "value": "バージョン %@ を保持" } },
"ko": { "stringUnit": { "state": "translated", "value": "버전 %@ 유지" } },
"ms": { "stringUnit": { "state": "translated", "value": "Simpan Versi %@" } },
"nl": { "stringUnit": { "state": "translated", "value": "Versie %@ bewaren" } },
"pl": { "stringUnit": { "state": "translated", "value": "Zachowaj wersję %@" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Manter versão %@" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Manter versão %@" } },
"ru": { "stringUnit": { "state": "translated", "value": "Оставить версию %@" } },
"sv": { "stringUnit": { "state": "translated", "value": "Behåll version %@" } },
"th": { "stringUnit": { "state": "translated", "value": "เก็บเวอร์ชัน %@" } },
"tr": { "stringUnit": { "state": "translated", "value": "%@ sürümünü tut" } },
"uk": { "stringUnit": { "state": "translated", "value": "Зберегти версію %@" } },
"vi": { "stringUnit": { "state": "translated", "value": "Giữ phiên bản %@" } }
}
},
"jotConflict.deviceLabel": {
"comment": "Label for the current device in conflict resolution",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "This Device" } },
"de": { "stringUnit": { "state": "translated", "value": "Dieses Gerät" } },
"af": { "stringUnit": { "state": "translated", "value": "Hierdie toestel" } },
"ar": { "stringUnit": { "state": "translated", "value": "هذا الجهاز" } },
"es": { "stringUnit": { "state": "translated", "value": "Este dispositivo" } },
"fr": { "stringUnit": { "state": "translated", "value": "Cet appareil" } },
"hi": { "stringUnit": { "state": "translated", "value": "यह डिवाइस" } },
"id": { "stringUnit": { "state": "translated", "value": "Perangkat Ini" } },
"it": { "stringUnit": { "state": "translated", "value": "Questo dispositivo" } },
"ja": { "stringUnit": { "state": "translated", "value": "このデバイス" } },
"ko": { "stringUnit": { "state": "translated", "value": "이 기기" } },
"ms": { "stringUnit": { "state": "translated", "value": "Peranti Ini" } },
"nl": { "stringUnit": { "state": "translated", "value": "Dit apparaat" } },
"pl": { "stringUnit": { "state": "translated", "value": "To urządzenie" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Este dispositivo" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Este dispositivo" } },
"ru": { "stringUnit": { "state": "translated", "value": "Это устройство" } },
"sv": { "stringUnit": { "state": "translated", "value": "Den här enheten" } },
"th": { "stringUnit": { "state": "translated", "value": "อุปกรณ์นี้" } },
"tr": { "stringUnit": { "state": "translated", "value": "Bu Cihaz" } },
"uk": { "stringUnit": { "state": "translated", "value": "Цей пристрій" } },
"vi": { "stringUnit": { "state": "translated", "value": "Thiết bị này" } }
}
},
"jotConflict.error.generic": {
"comment": "The error title displayed in the alert shown when a version resolution failed.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Could not resolve conflicts" } },
"de": { "stringUnit": { "state": "translated", "value": "Konflikte konnten nicht gelöst werden" } },
"af": { "stringUnit": { "state": "translated", "value": "Kon nie konflikte oplos nie" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعذّر حل التعارضات" } },
"es": { "stringUnit": { "state": "translated", "value": "No se pudieron resolver los conflictos" } },
"fr": { "stringUnit": { "state": "translated", "value": "Impossible de résoudre les conflits" } },
"hi": { "stringUnit": { "state": "translated", "value": "विरोधों को हल नहीं किया जा सका" } },
"id": { "stringUnit": { "state": "translated", "value": "Tidak dapat menyelesaikan konflik" } },
"it": { "stringUnit": { "state": "translated", "value": "Impossibile risolvere i conflitti" } },
"ja": { "stringUnit": { "state": "translated", "value": "競合を解決できませんでした" } },
"ko": { "stringUnit": { "state": "translated", "value": "충돌을 해결할 수 없음" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tidak dapat menyelesaikan konflik" } },
"nl": { "stringUnit": { "state": "translated", "value": "Kon conflicten niet oplossen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nie można rozwiązać konfliktów" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível resolver os conflitos" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Não foi possível resolver os conflitos" } },
"ru": { "stringUnit": { "state": "translated", "value": "Не удалось разрешить конфликты" } },
"sv": { "stringUnit": { "state": "translated", "value": "Kunde inte lösa konflikter" } },
"th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถแก้ไขข้อขัดแย้งได้" } },
"tr": { "stringUnit": { "state": "translated", "value": "Çakışmalar çözülemedi" } },
"uk": { "stringUnit": { "state": "translated", "value": "Не вдалося вирішити конфлікти" } },
"vi": { "stringUnit": { "state": "translated", "value": "Không thể giải quyết xung đột" } }
}
},
"jotConflict.subtitle": {
"comment": "Subtitle explaining the conflict; %@ is the name of the jot",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "\"%@\" was edited on two devices at the same time. Choose a version to keep." } },
"de": { "stringUnit": { "state": "translated", "value": "\"%@\" wurde gleichzeitig auf zwei Geräten bearbeitet. Wähle eine Version aus, die du behalten möchtest." } },
"af": { "stringUnit": { "state": "translated", "value": "\"%@\" is gelyktydig op twee toestelle gewysig. Kies 'n weergawe om te behou." } },
"ar": { "stringUnit": { "state": "translated", "value": "تم تعديل \"%@\" على جهازين في الوقت نفسه. اختر إصدارًا للاحتفاظ به." } },
"es": { "stringUnit": { "state": "translated", "value": "\"%@\" fue editado en dos dispositivos al mismo tiempo. Elige una versión para conservar." } },
"fr": { "stringUnit": { "state": "translated", "value": "« %@ » a été modifié sur deux appareils en même temps. Choisissez une version à conserver." } },
"hi": { "stringUnit": { "state": "translated", "value": "\"%@\" को एक साथ दो डिवाइस पर संपादित किया गया। रखने के लिए एक संस्करण चुनें।" } },
"id": { "stringUnit": { "state": "translated", "value": "\"%@\" diedit di dua perangkat secara bersamaan. Pilih versi yang ingin disimpan." } },
"it": { "stringUnit": { "state": "translated", "value": "«%@» è stato modificato su due dispositivi contemporaneamente. Scegli la versione da conservare." } },
"ja": { "stringUnit": { "state": "translated", "value": "「%@」が2つのデバイスで同時に編集されました。保持するバージョンを選んでください。" } },
"ko": { "stringUnit": { "state": "translated", "value": "\"%@\"이(가) 두 기기에서 동시에 편집되었습니다. 유지할 버전을 선택하세요." } },
"ms": { "stringUnit": { "state": "translated", "value": "\"%@\" telah diedit pada dua peranti pada masa yang sama. Pilih versi yang hendak disimpan." } },
"nl": { "stringUnit": { "state": "translated", "value": "\"%@\" is tegelijkertijd op twee apparaten bewerkt. Kies een versie om te bewaren." } },
"pl": { "stringUnit": { "state": "translated", "value": "\"%@\" zostało edytowane na dwóch urządzeniach jednocześnie. Wybierz wersję do zachowania." } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "\"%@\" foi editado em dois dispositivos ao mesmo tempo. Escolha uma versão para manter." } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "\"%@\" foi editado em dois dispositivos ao mesmo tempo. Escolha uma versão para manter." } },
"ru": { "stringUnit": { "state": "translated", "value": "«%@» было отредактировано на двух устройствах одновременно. Выберите версию для сохранения." } },
"sv": { "stringUnit": { "state": "translated", "value": "\"%@\" redigerades på två enheter samtidigt. Välj en version att behålla." } },
"th": { "stringUnit": { "state": "translated", "value": "\"%@\" ถูกแก้ไขบนสองอุปกรณ์พร้อมกัน เลือกเวอร์ชันที่ต้องการเก็บไว้" } },
"tr": { "stringUnit": { "state": "translated", "value": "\"%@\" aynı anda iki cihazda düzenlendi. Saklamak istediğiniz sürümü seçin." } },
"uk": { "stringUnit": { "state": "translated", "value": "«%@» було відредаговано на двох пристроях одночасно. Виберіть версію для збереження." } },
"vi": { "stringUnit": { "state": "translated", "value": "\"%@\" đã được chỉnh sửa trên hai thiết bị cùng lúc. Hãy chọn phiên bản muốn giữ lại." } }
}
},
"jotConflict.title": {
"comment": "Headline on the jot conflict resolution screen",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Version Conflict" } },
"de": { "stringUnit": { "state": "translated", "value": "Versionskonflikt" } },
"af": { "stringUnit": { "state": "translated", "value": "Weergawekonflikt" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعارض الإصدارات" } },
"es": { "stringUnit": { "state": "translated", "value": "Conflicto de versiones" } },
"fr": { "stringUnit": { "state": "translated", "value": "Conflit de versions" } },
"hi": { "stringUnit": { "state": "translated", "value": "संस्करण विरोध" } },
"id": { "stringUnit": { "state": "translated", "value": "Konflik Versi" } },
"it": { "stringUnit": { "state": "translated", "value": "Conflitto di versioni" } },
"ja": { "stringUnit": { "state": "translated", "value": "バージョンの競合" } },
"ko": { "stringUnit": { "state": "translated", "value": "버전 충돌" } },
"ms": { "stringUnit": { "state": "translated", "value": "Konflik Versi" } },
"nl": { "stringUnit": { "state": "translated", "value": "Versieconflict" } },
"pl": { "stringUnit": { "state": "translated", "value": "Konflikt wersji" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Conflito de versões" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Conflito de versões" } },
"ru": { "stringUnit": { "state": "translated", "value": "Конфликт версий" } },
"sv": { "stringUnit": { "state": "translated", "value": "Versionskonflikt" } },
"th": { "stringUnit": { "state": "translated", "value": "ความขัดแย้งของเวอร์ชัน" } },
"tr": { "stringUnit": { "state": "translated", "value": "Sürüm Çakışması" } },
"uk": { "stringUnit": { "state": "translated", "value": "Конфлікт версій" } },
"vi": { "stringUnit": { "state": "translated", "value": "Xung đột phiên bản" } }
}
},
"jotConflict.versionName": {
"comment": "Label for conflict version (displays as 'Version A', 'Version B', etc.)",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Version %@" } },
"de": { "stringUnit": { "state": "translated", "value": "Version %@" } },
"af": { "stringUnit": { "state": "translated", "value": "Weergawe %@" } },
"ar": { "stringUnit": { "state": "translated", "value": "الإصدار %@" } },
"es": { "stringUnit": { "state": "translated", "value": "Versión %@" } },
"fr": { "stringUnit": { "state": "translated", "value": "Version %@" } },
"hi": { "stringUnit": { "state": "translated", "value": "संस्करण %@" } },
"id": { "stringUnit": { "state": "translated", "value": "Versi %@" } },
"it": { "stringUnit": { "state": "translated", "value": "Versione %@" } },
"ja": { "stringUnit": { "state": "translated", "value": "バージョン %@" } },
"ko": { "stringUnit": { "state": "translated", "value": "버전 %@" } },
"ms": { "stringUnit": { "state": "translated", "value": "Versi %@" } },
"nl": { "stringUnit": { "state": "translated", "value": "Versie %@" } },
"pl": { "stringUnit": { "state": "translated", "value": "Wersja %@" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Versão %@" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Versão %@" } },
"ru": { "stringUnit": { "state": "translated", "value": "Версия %@" } },
"sv": { "stringUnit": { "state": "translated", "value": "Version %@" } },
"th": { "stringUnit": { "state": "translated", "value": "เวอร์ชัน %@" } },
"tr": { "stringUnit": { "state": "translated", "value": "Sürüm %@" } },
"uk": { "stringUnit": { "state": "translated", "value": "Версія %@" } },
"vi": { "stringUnit": { "state": "translated", "value": "Phiên bản %@" } }
}
},
"jots.create.error.fileExists": {
"comment": "The error message when a Jot already exists.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "\"%@\" already exists" } },
"de": { "stringUnit": { "state": "translated", "value": "\"%@\" ist bereits vorhanden" } },
"af": { "stringUnit": { "state": "translated", "value": "\"%@\" bestaan reeds" } },
"ar": { "stringUnit": { "state": "translated", "value": "\"%@\" موجود بالفعل" } },
"es": { "stringUnit": { "state": "translated", "value": "\"%@\" ya existe" } },
"fr": { "stringUnit": { "state": "translated", "value": "« %@ » existe déjà" } },
"hi": { "stringUnit": { "state": "translated", "value": "\"%@\" पहले से मौजूद है" } },
"id": { "stringUnit": { "state": "translated", "value": "\"%@\" sudah ada" } },
"it": { "stringUnit": { "state": "translated", "value": "«%@» esiste già" } },
"ja": { "stringUnit": { "state": "translated", "value": "「%@」はすでに存在します" } },
"ko": { "stringUnit": { "state": "translated", "value": "\"%@\"이(가) 이미 존재합니다" } },
"ms": { "stringUnit": { "state": "translated", "value": "\"%@\" sudah wujud" } },
"nl": { "stringUnit": { "state": "translated", "value": "\"%@\" bestaat al" } },
"pl": { "stringUnit": { "state": "translated", "value": "\"%@\" już istnieje" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "\"%@\" já existe" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "\"%@\" já existe" } },
"ru": { "stringUnit": { "state": "translated", "value": "«%@» уже существует" } },
"sv": { "stringUnit": { "state": "translated", "value": "\"%@\" finns redan" } },
"th": { "stringUnit": { "state": "translated", "value": "\"%@\" มีอยู่แล้ว" } },
"tr": { "stringUnit": { "state": "translated", "value": "\"%@\" zaten mevcut" } },
"uk": { "stringUnit": { "state": "translated", "value": "«%@» вже існує" } },
"vi": { "stringUnit": { "state": "translated", "value": "\"%@\" đã tồn tại" } }
}
},
"jots.create.error.generic": {
"comment": "A generic error message during Jot creation.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Something went wrong" } },
"de": { "stringUnit": { "state": "translated", "value": "Etwas ist schiefgelaufen" } },
"af": { "stringUnit": { "state": "translated", "value": "Iets het verkeerd geloop" } },
"ar": { "stringUnit": { "state": "translated", "value": "حدث خطأ ما" } },
"es": { "stringUnit": { "state": "translated", "value": "Algo salió mal" } },
"fr": { "stringUnit": { "state": "translated", "value": "Une erreur s'est produite" } },
"hi": { "stringUnit": { "state": "translated", "value": "कुछ गलत हो गया" } },
"id": { "stringUnit": { "state": "translated", "value": "Terjadi kesalahan" } },
"it": { "stringUnit": { "state": "translated", "value": "Qualcosa è andato storto" } },
"ja": { "stringUnit": { "state": "translated", "value": "問題が発生しました" } },
"ko": { "stringUnit": { "state": "translated", "value": "문제가 발생했습니다" } },
"ms": { "stringUnit": { "state": "translated", "value": "Sesuatu telah berlaku" } },
"nl": { "stringUnit": { "state": "translated", "value": "Er is iets misgegaan" } },
"pl": { "stringUnit": { "state": "translated", "value": "Coś poszło nie tak" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Algo deu errado" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Algo correu mal" } },
"ru": { "stringUnit": { "state": "translated", "value": "Что-то пошло не так" } },
"sv": { "stringUnit": { "state": "translated", "value": "Något gick fel" } },
"th": { "stringUnit": { "state": "translated", "value": "เกิดข้อผิดพลาดบางอย่าง" } },
"tr": { "stringUnit": { "state": "translated", "value": "Bir şeyler ters gitti" } },
"uk": { "stringUnit": { "state": "translated", "value": "Щось пішло не так" } },
"vi": { "stringUnit": { "state": "translated", "value": "Đã xảy ra lỗi" } }
}
},
"jots.create.namePlaceholder": {
"comment": "Placeholder text in the name text field of the Create Jot alert",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Name" } },
"de": { "stringUnit": { "state": "translated", "value": "Name" } },
"af": { "stringUnit": { "state": "translated", "value": "Naam" } },
"ar": { "stringUnit": { "state": "translated", "value": "الاسم" } },
"es": { "stringUnit": { "state": "translated", "value": "Nombre" } },
"fr": { "stringUnit": { "state": "translated", "value": "Nom" } },
"hi": { "stringUnit": { "state": "translated", "value": "नाम" } },
"id": { "stringUnit": { "state": "translated", "value": "Nama" } },
"it": { "stringUnit": { "state": "translated", "value": "Nome" } },
"ja": { "stringUnit": { "state": "translated", "value": "名前" } },
"ko": { "stringUnit": { "state": "translated", "value": "이름" } },
"ms": { "stringUnit": { "state": "translated", "value": "Nama" } },
"nl": { "stringUnit": { "state": "translated", "value": "Naam" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nazwa" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Nome" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Nome" } },
"ru": { "stringUnit": { "state": "translated", "value": "Название" } },
"sv": { "stringUnit": { "state": "translated", "value": "Namn" } },
"th": { "stringUnit": { "state": "translated", "value": "ชื่อ" } },
"tr": { "stringUnit": { "state": "translated", "value": "Ad" } },
"uk": { "stringUnit": { "state": "translated", "value": "Назва" } },
"vi": { "stringUnit": { "state": "translated", "value": "Tên" } }
}
},
"jots.create.title": {
"comment": "Title of the Create Jot alert",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "New Jot" } },
"de": { "stringUnit": { "state": "translated", "value": "Neues Jot" } },
"af": { "stringUnit": { "state": "translated", "value": "Nuwe Jot" } },
"ar": { "stringUnit": { "state": "translated", "value": "Jot جديد" } },
"es": { "stringUnit": { "state": "translated", "value": "Nuevo Jot" } },
"fr": { "stringUnit": { "state": "translated", "value": "Nouveau Jot" } },
"hi": { "stringUnit": { "state": "translated", "value": "नया Jot" } },
"id": { "stringUnit": { "state": "translated", "value": "Jot Baru" } },
"it": { "stringUnit": { "state": "translated", "value": "Nuovo Jot" } },
"ja": { "stringUnit": { "state": "translated", "value": "新しいJot" } },
"ko": { "stringUnit": { "state": "translated", "value": "새 Jot" } },
"ms": { "stringUnit": { "state": "translated", "value": "Jot Baharu" } },
"nl": { "stringUnit": { "state": "translated", "value": "Nieuwe Jot" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nowy Jot" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Novo Jot" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Novo Jot" } },
"ru": { "stringUnit": { "state": "translated", "value": "Новый Jot" } },
"sv": { "stringUnit": { "state": "translated", "value": "Ny Jot" } },
"th": { "stringUnit": { "state": "translated", "value": "Jot ใหม่" } },
"tr": { "stringUnit": { "state": "translated", "value": "Yeni Jot" } },
"uk": { "stringUnit": { "state": "translated", "value": "Новий Jot" } },
"vi": { "stringUnit": { "state": "translated", "value": "Jot mới" } }
}
},
"jots.delete.error.generic": {
"comment": "The error message when a Jot couldn't be deleted.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Could not delete \"%@\"" } },
"de": { "stringUnit": { "state": "translated", "value": "\"%@\" konnte nicht gelöscht werden" } },
"af": { "stringUnit": { "state": "translated", "value": "Kon \"%@\" nie verwyder nie" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعذّر حذف \"%@\"" } },
"es": { "stringUnit": { "state": "translated", "value": "No se pudo eliminar \"%@\"" } },
"fr": { "stringUnit": { "state": "translated", "value": "Impossible de supprimer « %@ »" } },
"hi": { "stringUnit": { "state": "translated", "value": "\"%@\" को हटाया नहीं जा सका" } },
"id": { "stringUnit": { "state": "translated", "value": "Tidak dapat menghapus \"%@\"" } },
"it": { "stringUnit": { "state": "translated", "value": "Impossibile eliminare «%@»" } },
"ja": { "stringUnit": { "state": "translated", "value": "「%@」を削除できませんでした" } },
"ko": { "stringUnit": { "state": "translated", "value": "\"%@\"을(를) 삭제할 수 없음" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tidak dapat memadam \"%@\"" } },
"nl": { "stringUnit": { "state": "translated", "value": "Kan \"%@\" niet verwijderen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nie można usunąć \"%@\"" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível excluir \"%@\"" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Não foi possível eliminar \"%@\"" } },
"ru": { "stringUnit": { "state": "translated", "value": "Не удалось удалить «%@»" } },
"sv": { "stringUnit": { "state": "translated", "value": "Kunde inte radera \"%@\"" } },
"th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถลบ \"%@\" ได้" } },
"tr": { "stringUnit": { "state": "translated", "value": "\"%@\" silinemedi" } },
"uk": { "stringUnit": { "state": "translated", "value": "Не вдалося видалити «%@»" } },
"vi": { "stringUnit": { "state": "translated", "value": "Không thể xóa \"%@\"" } }
}
},
"jots.delete.message": {
"comment": "Confirmation message in the Delete Jot alert",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Are you sure you want to delete this Jot? This action cannot be undone." } },
"de": { "stringUnit": { "state": "translated", "value": "Möchtest du dieses Jot wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." } },
"af": { "stringUnit": { "state": "translated", "value": "Is jy seker jy wil hierdie Jot verwyder? Hierdie aksie kan nie ongedaan gemaak word nie." } },
"ar": { "stringUnit": { "state": "translated", "value": "هل أنت متأكد أنك تريد حذف هذا Jot؟ لا يمكن التراجع عن هذا الإجراء." } },
"es": { "stringUnit": { "state": "translated", "value": "¿Seguro que quieres eliminar este Jot? Esta acción no se puede deshacer." } },
"fr": { "stringUnit": { "state": "translated", "value": "Voulez-vous vraiment supprimer ce Jot ? Cette action est irréversible." } },
"hi": { "stringUnit": { "state": "translated", "value": "क्या आप वाकई इस Jot को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।" } },
"id": { "stringUnit": { "state": "translated", "value": "Yakin ingin menghapus Jot ini? Tindakan ini tidak dapat dibatalkan." } },
"it": { "stringUnit": { "state": "translated", "value": "Sei sicuro di voler eliminare questo Jot? L'operazione non può essere annullata." } },
"ja": { "stringUnit": { "state": "translated", "value": "このJotを削除してもよいですか?この操作は元に戻せません。" } },
"ko": { "stringUnit": { "state": "translated", "value": "이 Jot을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다." } },
"ms": { "stringUnit": { "state": "translated", "value": "Adakah anda pasti ingin memadam Jot ini? Tindakan ini tidak boleh dibuat asal." } },
"nl": { "stringUnit": { "state": "translated", "value": "Weet je zeker dat je deze Jot wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." } },
"pl": { "stringUnit": { "state": "translated", "value": "Czy na pewno chcesz usunąć ten Jot? Tej operacji nie można cofnąć." } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Tem certeza de que deseja excluir este Jot? Esta ação não pode ser desfeita." } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Tem a certeza de que pretende eliminar este Jot? Esta ação não pode ser anulada." } },
"ru": { "stringUnit": { "state": "translated", "value": "Вы уверены, что хотите удалить этот Jot? Это действие нельзя отменить." } },
"sv": { "stringUnit": { "state": "translated", "value": "Är du säker på att du vill radera den här Jot? Den här åtgärden kan inte ångras." } },
"th": { "stringUnit": { "state": "translated", "value": "คุณแน่ใจว่าต้องการลบ Jot นี้ใช่ไหม? การดำเนินการนี้ไม่สามารถย้อนกลับได้" } },
"tr": { "stringUnit": { "state": "translated", "value": "Bu Jot'u silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." } },
"uk": { "stringUnit": { "state": "translated", "value": "Ви впевнені, що хочете видалити цей Jot? Цю дію неможливо скасувати." } },
"vi": { "stringUnit": { "state": "translated", "value": "Bạn có chắc muốn xóa Jot này không? Hành động này không thể hoàn tác." } }
}
},
"jots.delete.title": {
"comment": "Title of the Delete Jot confirmation alert",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Delete Jot" } },
"de": { "stringUnit": { "state": "translated", "value": "Jot löschen" } },
"af": { "stringUnit": { "state": "translated", "value": "Verwyder Jot" } },
"ar": { "stringUnit": { "state": "translated", "value": "حذف Jot" } },
"es": { "stringUnit": { "state": "translated", "value": "Eliminar Jot" } },
"fr": { "stringUnit": { "state": "translated", "value": "Supprimer le Jot" } },
"hi": { "stringUnit": { "state": "translated", "value": "Jot हटाएं" } },
"id": { "stringUnit": { "state": "translated", "value": "Hapus Jot" } },
"it": { "stringUnit": { "state": "translated", "value": "Elimina Jot" } },
"ja": { "stringUnit": { "state": "translated", "value": "Jotを削除" } },
"ko": { "stringUnit": { "state": "translated", "value": "Jot 삭제" } },
"ms": { "stringUnit": { "state": "translated", "value": "Padam Jot" } },
"nl": { "stringUnit": { "state": "translated", "value": "Jot verwijderen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Usuń Jot" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Excluir Jot" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Eliminar Jot" } },
"ru": { "stringUnit": { "state": "translated", "value": "Удалить Jot" } },
"sv": { "stringUnit": { "state": "translated", "value": "Radera Jot" } },
"th": { "stringUnit": { "state": "translated", "value": "ลบ Jot" } },
"tr": { "stringUnit": { "state": "translated", "value": "Jot'u Sil" } },
"uk": { "stringUnit": { "state": "translated", "value": "Видалити Jot" } },
"vi": { "stringUnit": { "state": "translated", "value": "Xóa Jot" } }
}
},
"jots.download.error.generic": {
"comment": "The error message displayed when a Jot couldn't be downloaded.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Could not download \"%@\"" } },
"de": { "stringUnit": { "state": "translated", "value": "\"%@\" konnte nicht heruntergeladen werden" } },
"af": { "stringUnit": { "state": "translated", "value": "Kon \"%@\" nie aflaai nie" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعذّر تنزيل \"%@\"" } },
"es": { "stringUnit": { "state": "translated", "value": "No se pudo descargar \"%@\"" } },
"fr": { "stringUnit": { "state": "translated", "value": "Impossible de télécharger « %@ »" } },
"hi": { "stringUnit": { "state": "translated", "value": "\"%@\" डाउनलोड नहीं हो सका" } },
"id": { "stringUnit": { "state": "translated", "value": "Tidak dapat mengunduh \"%@\"" } },
"it": { "stringUnit": { "state": "translated", "value": "Impossibile scaricare «%@»" } },
"ja": { "stringUnit": { "state": "translated", "value": "「%@」をダウンロードできませんでした" } },
"ko": { "stringUnit": { "state": "translated", "value": "\"%@\"을(를) 다운로드할 수 없음" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tidak dapat memuat turun \"%@\"" } },
"nl": { "stringUnit": { "state": "translated", "value": "Kan \"%@\" niet downloaden" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nie można pobrać \"%@\"" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível baixar \"%@\"" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Não foi possível transferir \"%@\"" } },
"ru": { "stringUnit": { "state": "translated", "value": "Не удалось загрузить «%@»" } },
"sv": { "stringUnit": { "state": "translated", "value": "Kunde inte ladda ned \"%@\"" } },
"th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถดาวน์โหลด \"%@\" ได้" } },
"tr": { "stringUnit": { "state": "translated", "value": "\"%@\" indirilemedi" } },
"uk": { "stringUnit": { "state": "translated", "value": "Не вдалося завантажити «%@»" } },
"vi": { "stringUnit": { "state": "translated", "value": "Không thể tải xuống \"%@\"" } }
}
},
"jots.duplicate.error.generic": {
"comment": "The error message displayed when a Jot couldn't be duplicated.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Could not duplicate \"%@\"" } },
"de": { "stringUnit": { "state": "translated", "value": "\"%@\" konnte nicht dupliziert werden" } },
"af": { "stringUnit": { "state": "translated", "value": "Kon \"%@\" nie dupliseer nie" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعذّر تكرار \"%@\"" } },
"es": { "stringUnit": { "state": "translated", "value": "No se pudo duplicar \"%@\"" } },
"fr": { "stringUnit": { "state": "translated", "value": "Impossible de dupliquer « %@ »" } },
"hi": { "stringUnit": { "state": "translated", "value": "\"%@\" को डुप्लीकेट नहीं किया जा सका" } },
"id": { "stringUnit": { "state": "translated", "value": "Tidak dapat menduplikasi \"%@\"" } },
"it": { "stringUnit": { "state": "translated", "value": "Impossibile duplicare «%@»" } },
"ja": { "stringUnit": { "state": "translated", "value": "「%@」を複製できませんでした" } },
"ko": { "stringUnit": { "state": "translated", "value": "\"%@\"을(를) 복제할 수 없음" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tidak dapat menduplikasi \"%@\"" } },
"nl": { "stringUnit": { "state": "translated", "value": "Kan \"%@\" niet dupliceren" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nie można zduplikować \"%@\"" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível duplicar \"%@\"" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Não foi possível duplicar \"%@\"" } },
"ru": { "stringUnit": { "state": "translated", "value": "Не удалось дублировать «%@»" } },
"sv": { "stringUnit": { "state": "translated", "value": "Kunde inte duplicera \"%@\"" } },
"th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถทำซ้ำ \"%@\" ได้" } },
"tr": { "stringUnit": { "state": "translated", "value": "\"%@\" çoğaltılamadı" } },
"uk": { "stringUnit": { "state": "translated", "value": "Не вдалося дублювати «%@»" } },
"vi": { "stringUnit": { "state": "translated", "value": "Không thể nhân đôi \"%@\"" } }
}
},
"jots.empty.title": {
"comment": "Empty state message when there are no jots",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "A blank page full of possibilities. Go ahead, jot something insanely great!" } },
"de": { "stringUnit": { "state": "translated", "value": "Eine leere Seite voller Möglichkeiten. Los geht's, schreib etwas Großartiges!" } },
"af": { "stringUnit": { "state": "translated", "value": "'n Leë bladsy vol moontlikhede. Gaan voort, skryf iets ongelooflik wonderlik neer!" } },
"ar": { "stringUnit": { "state": "translated", "value": "صفحة بيضاء مليئة بالإمكانيات. هيّا، دوّن شيئًا رائعًا!" } },
"es": { "stringUnit": { "state": "translated", "value": "Una página en blanco llena de posibilidades. ¡Adelante, escribe algo increíble!" } },
"fr": { "stringUnit": { "state": "translated", "value": "Une page blanche pleine de possibilités. Allez, notez quelque chose d'extraordinaire !" } },
"hi": { "stringUnit": { "state": "translated", "value": "संभावनाओं से भरा एक खाली पन्ना। आगे बढ़ें, कुछ शानदार लिखें!" } },
"id": { "stringUnit": { "state": "translated", "value": "Halaman kosong penuh kemungkinan. Ayo, tuliskan sesuatu yang luar biasa!" } },
"it": { "stringUnit": { "state": "translated", "value": "Una pagina bianca piena di possibilità. Dai, annota qualcosa di straordinario!" } },
"ja": { "stringUnit": { "state": "translated", "value": "可能性に満ちた白紙のページ。さあ、素晴らしいことを書き留めましょう!" } },
"ko": { "stringUnit": { "state": "translated", "value": "가능성으로 가득 찬 빈 페이지. 지금 바로 멋진 것을 적어 보세요!" } },
"ms": { "stringUnit": { "state": "translated", "value": "Halaman kosong penuh kemungkinan. Ayuh, catat sesuatu yang luar biasa!" } },
"nl": { "stringUnit": { "state": "translated", "value": "Een blanco pagina vol mogelijkheden. Ga je gang en schrijf iets geweldigs!" } },
"pl": { "stringUnit": { "state": "translated", "value": "Pusta strona pełna możliwości. No dalej, zapisz coś niesamowitego!" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Uma página em branco cheia de possibilidades. Vá em frente, anote algo incrível!" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Uma página em branco cheia de possibilidades. Avance, escreva algo incrivelmente fantástico!" } },
"ru": { "stringUnit": { "state": "translated", "value": "Чистая страница, полная возможностей. Вперёд — запишите что-нибудь грандиозное!" } },
"sv": { "stringUnit": { "state": "translated", "value": "En tom sida full av möjligheter. Sätt igång och anteckna något fantastiskt!" } },
"th": { "stringUnit": { "state": "translated", "value": "หน้ากระดาษเปล่าเต็มไปด้วยความเป็นไปได้ ลองจดบางอย่างที่ยอดเยี่ยมดูสิ!" } },
"tr": { "stringUnit": { "state": "translated", "value": "Olasılıklarla dolu boş bir sayfa. Haydi, harika bir şeyler jot edin!" } },
"uk": { "stringUnit": { "state": "translated", "value": "Чиста сторінка, сповнена можливостей. Сміливо — занотуйте щось неймовірне!" } },
"vi": { "stringUnit": { "state": "translated", "value": "Một trang trắng đầy khả năng. Hãy mạnh dạn ghi lại điều gì đó thật tuyệt vời!" } }
}
},
"jots.menu.openInNewWindow": {
"comment": "Context menu item to open a jot in a new window",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Open In New Window" } },
"de": { "stringUnit": { "state": "translated", "value": "In neuem Fenster öffnen" } },
"af": { "stringUnit": { "state": "translated", "value": "Maak oop in nuwe venster" } },
"ar": { "stringUnit": { "state": "translated", "value": "فتح في نافذة جديدة" } },
"es": { "stringUnit": { "state": "translated", "value": "Abrir en nueva ventana" } },
"fr": { "stringUnit": { "state": "translated", "value": "Ouvrir dans une nouvelle fenêtre" } },
"hi": { "stringUnit": { "state": "translated", "value": "नई विंडो में खोलें" } },
"id": { "stringUnit": { "state": "translated", "value": "Buka di Jendela Baru" } },
"it": { "stringUnit": { "state": "translated", "value": "Apri in una nuova finestra" } },
"ja": { "stringUnit": { "state": "translated", "value": "新しいウインドウで開く" } },
"ko": { "stringUnit": { "state": "translated", "value": "새 윈도우에서 열기" } },
"ms": { "stringUnit": { "state": "translated", "value": "Buka dalam Tetingkap Baharu" } },
"nl": { "stringUnit": { "state": "translated", "value": "Open in nieuw venster" } },
"pl": { "stringUnit": { "state": "translated", "value": "Otwórz w nowym oknie" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Abrir em nova janela" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Abrir em nova janela" } },
"ru": { "stringUnit": { "state": "translated", "value": "Открыть в новом окне" } },
"sv": { "stringUnit": { "state": "translated", "value": "Öppna i nytt fönster" } },
"th": { "stringUnit": { "state": "translated", "value": "เปิดในหน้าต่างใหม่" } },
"tr": { "stringUnit": { "state": "translated", "value": "Yeni Pencerede Aç" } },
"uk": { "stringUnit": { "state": "translated", "value": "Відкрити в новому вікні" } },
"vi": { "stringUnit": { "state": "translated", "value": "Mở trong cửa sổ mới" } }
}
},
"jots.menu.revealInFiles": {
"comment": "Context menu item to reveal the jot in the Files app (iOS only)",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Reveal in Files" } },
"de": { "stringUnit": { "state": "translated", "value": "In Dateien anzeigen" } },
"af": { "stringUnit": { "state": "translated", "value": "Wys in Lêers" } },
"ar": { "stringUnit": { "state": "translated", "value": "الكشف في الملفات" } },
"es": { "stringUnit": { "state": "translated", "value": "Mostrar en Archivos" } },
"fr": { "stringUnit": { "state": "translated", "value": "Afficher dans Fichiers" } },
"hi": { "stringUnit": { "state": "translated", "value": "Files में दिखाएं" } },
"id": { "stringUnit": { "state": "translated", "value": "Tampilkan di Files" } },
"it": { "stringUnit": { "state": "translated", "value": "Mostra in File" } },
"ja": { "stringUnit": { "state": "translated", "value": "ファイルで表示" } },
"ko": { "stringUnit": { "state": "translated", "value": "파일에서 보기" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tunjukkan dalam Fail" } },
"nl": { "stringUnit": { "state": "translated", "value": "Tonen in Bestanden" } },
"pl": { "stringUnit": { "state": "translated", "value": "Pokaż w Plikach" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Mostrar em Arquivos" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Mostrar em Ficheiros" } },
"ru": { "stringUnit": { "state": "translated", "value": "Показать в Файлах" } },
"sv": { "stringUnit": { "state": "translated", "value": "Visa i Filer" } },
"th": { "stringUnit": { "state": "translated", "value": "แสดงใน Files" } },
"tr": { "stringUnit": { "state": "translated", "value": "Dosyalar'da Göster" } },
"uk": { "stringUnit": { "state": "translated", "value": "Показати у Файлах" } },
"vi": { "stringUnit": { "state": "translated", "value": "Hiện trong Files" } }
}
},
"jots.menu.revealInFinder": {
"comment": "Context menu item to reveal the jot in Finder (macOS Catalyst only)",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Reveal in Finder" } },
"de": { "stringUnit": { "state": "translated", "value": "Im Finder anzeigen" } },
"af": { "stringUnit": { "state": "translated", "value": "Wys in Finder" } },
"ar": { "stringUnit": { "state": "translated", "value": "الكشف في Finder" } },
"es": { "stringUnit": { "state": "translated", "value": "Mostrar en el Finder" } },
"fr": { "stringUnit": { "state": "translated", "value": "Afficher dans le Finder" } },
"hi": { "stringUnit": { "state": "translated", "value": "Finder में दिखाएं" } },
"id": { "stringUnit": { "state": "translated", "value": "Tampilkan di Finder" } },
"it": { "stringUnit": { "state": "translated", "value": "Mostra nel Finder" } },
"ja": { "stringUnit": { "state": "translated", "value": "Finderで表示" } },
"ko": { "stringUnit": { "state": "translated", "value": "Finder에서 보기" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tunjukkan dalam Finder" } },
"nl": { "stringUnit": { "state": "translated", "value": "Tonen in Finder" } },
"pl": { "stringUnit": { "state": "translated", "value": "Pokaż w Finderze" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Mostrar no Finder" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Mostrar no Finder" } },
"ru": { "stringUnit": { "state": "translated", "value": "Показать в Finder" } },
"sv": { "stringUnit": { "state": "translated", "value": "Visa i Finder" } },
"th": { "stringUnit": { "state": "translated", "value": "แสดงใน Finder" } },
"tr": { "stringUnit": { "state": "translated", "value": "Finder'da Göster" } },
"uk": { "stringUnit": { "state": "translated", "value": "Показати у Finder" } },
"vi": { "stringUnit": { "state": "translated", "value": "Hiện trong Finder" } }
}
},
"jots.rename.error.generic": {
"comment": "The error message displayed when a Jot couldn't be renamed.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Could not rename \"%@\"" } },
"de": { "stringUnit": { "state": "translated", "value": "\"%@\" konnte nicht umbenannt werden" } },
"af": { "stringUnit": { "state": "translated", "value": "Kon \"%@\" nie hernoem nie" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعذّر إعادة تسمية \"%@\"" } },
"es": { "stringUnit": { "state": "translated", "value": "No se pudo renombrar \"%@\"" } },
"fr": { "stringUnit": { "state": "translated", "value": "Impossible de renommer « %@ »" } },
"hi": { "stringUnit": { "state": "translated", "value": "\"%@\" का नाम नहीं बदला जा सका" } },
"id": { "stringUnit": { "state": "translated", "value": "Tidak dapat mengganti nama \"%@\"" } },
"it": { "stringUnit": { "state": "translated", "value": "Impossibile rinominare «%@»" } },
"ja": { "stringUnit": { "state": "translated", "value": "「%@」の名前を変更できませんでした" } },
"ko": { "stringUnit": { "state": "translated", "value": "\"%@\"의 이름을 변경할 수 없음" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tidak dapat menamakan semula \"%@\"" } },
"nl": { "stringUnit": { "state": "translated", "value": "Kan \"%@\" niet hernoemen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nie można zmienić nazwy \"%@\"" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível renomear \"%@\"" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Não foi possível renomear \"%@\"" } },
"ru": { "stringUnit": { "state": "translated", "value": "Не удалось переименовать «%@»" } },
"sv": { "stringUnit": { "state": "translated", "value": "Kunde inte byta namn på \"%@\"" } },
"th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถเปลี่ยนชื่อ \"%@\" ได้" } },
"tr": { "stringUnit": { "state": "translated", "value": "\"%@\" yeniden adlandırılamadı" } },
"uk": { "stringUnit": { "state": "translated", "value": "Не вдалося перейменувати «%@»" } },
"vi": { "stringUnit": { "state": "translated", "value": "Không thể đổi tên \"%@\"" } }
}
},
"jots.share.error.generic": {
"comment": "The error message displayed when a Jot couldn't be shared.",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Could not share \"%@\"" } },
"de": { "stringUnit": { "state": "translated", "value": "\"%@\" konnte nicht geteilt werden" } },
"af": { "stringUnit": { "state": "translated", "value": "Kon \"%@\" nie deel nie" } },
"ar": { "stringUnit": { "state": "translated", "value": "تعذّر مشاركة \"%@\"" } },
"es": { "stringUnit": { "state": "translated", "value": "No se pudo compartir \"%@\"" } },
"fr": { "stringUnit": { "state": "translated", "value": "Impossible de partager « %@ »" } },
"hi": { "stringUnit": { "state": "translated", "value": "\"%@\" को साझा नहीं किया जा सका" } },
"id": { "stringUnit": { "state": "translated", "value": "Tidak dapat membagikan \"%@\"" } },
"it": { "stringUnit": { "state": "translated", "value": "Impossibile condividere «%@»" } },
"ja": { "stringUnit": { "state": "translated", "value": "「%@」を共有できませんでした" } },
"ko": { "stringUnit": { "state": "translated", "value": "\"%@\"을(를) 공유할 수 없음" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tidak dapat berkongsi \"%@\"" } },
"nl": { "stringUnit": { "state": "translated", "value": "Kan \"%@\" niet delen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Nie można udostępnić \"%@\"" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível compartilhar \"%@\"" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Não foi possível partilhar \"%@\"" } },
"ru": { "stringUnit": { "state": "translated", "value": "Не удалось поделиться «%@»" } },
"sv": { "stringUnit": { "state": "translated", "value": "Kunde inte dela \"%@\"" } },
"th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถแชร์ \"%@\" ได้" } },
"tr": { "stringUnit": { "state": "translated", "value": "\"%@\" paylaşılamadı" } },
"uk": { "stringUnit": { "state": "translated", "value": "Не вдалося поділитися «%@»" } },
"vi": { "stringUnit": { "state": "translated", "value": "Không thể chia sẻ \"%@\"" } }
}
},
"jots.rename.title": {
"comment": "Title of the Rename Jot alert",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Rename Jot" } },
"de": { "stringUnit": { "state": "translated", "value": "Jot umbenennen" } },
"af": { "stringUnit": { "state": "translated", "value": "Hernoem Jot" } },
"ar": { "stringUnit": { "state": "translated", "value": "إعادة تسمية Jot" } },
"es": { "stringUnit": { "state": "translated", "value": "Renombrar Jot" } },
"fr": { "stringUnit": { "state": "translated", "value": "Renommer le Jot" } },
"hi": { "stringUnit": { "state": "translated", "value": "Jot का नाम बदलें" } },
"id": { "stringUnit": { "state": "translated", "value": "Ganti Nama Jot" } },
"it": { "stringUnit": { "state": "translated", "value": "Rinomina Jot" } },
"ja": { "stringUnit": { "state": "translated", "value": "Jotの名前を変更" } },
"ko": { "stringUnit": { "state": "translated", "value": "Jot 이름 변경" } },
"ms": { "stringUnit": { "state": "translated", "value": "Namakan Semula Jot" } },
"nl": { "stringUnit": { "state": "translated", "value": "Jot hernoemen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Zmień nazwę Jot" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Renomear Jot" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Renomear Jot" } },
"ru": { "stringUnit": { "state": "translated", "value": "Переименовать Jot" } },
"sv": { "stringUnit": { "state": "translated", "value": "Byt namn på Jot" } },
"th": { "stringUnit": { "state": "translated", "value": "เปลี่ยนชื่อ Jot" } },
"tr": { "stringUnit": { "state": "translated", "value": "Jot'u Yeniden Adlandır" } },
"uk": { "stringUnit": { "state": "translated", "value": "Перейменувати Jot" } },
"vi": { "stringUnit": { "state": "translated", "value": "Đổi tên Jot" } }
}
},
"settings.appearance.dark": {
"comment": "Appearance option label for dark mode",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Dark" } },
"de": { "stringUnit": { "state": "translated", "value": "Dunkel" } },
"af": { "stringUnit": { "state": "translated", "value": "Donker" } },
"ar": { "stringUnit": { "state": "translated", "value": "داكن" } },
"es": { "stringUnit": { "state": "translated", "value": "Oscuro" } },
"fr": { "stringUnit": { "state": "translated", "value": "Sombre" } },
"hi": { "stringUnit": { "state": "translated", "value": "डार्क" } },
"id": { "stringUnit": { "state": "translated", "value": "Gelap" } },
"it": { "stringUnit": { "state": "translated", "value": "Scuro" } },
"ja": { "stringUnit": { "state": "translated", "value": "ダーク" } },
"ko": { "stringUnit": { "state": "translated", "value": "어둡게" } },
"ms": { "stringUnit": { "state": "translated", "value": "Gelap" } },
"nl": { "stringUnit": { "state": "translated", "value": "Donker" } },
"pl": { "stringUnit": { "state": "translated", "value": "Ciemny" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Escuro" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Escuro" } },
"ru": { "stringUnit": { "state": "translated", "value": "Тёмная" } },
"sv": { "stringUnit": { "state": "translated", "value": "Mörkt" } },
"th": { "stringUnit": { "state": "translated", "value": "มืด" } },
"tr": { "stringUnit": { "state": "translated", "value": "Koyu" } },
"uk": { "stringUnit": { "state": "translated", "value": "Темна" } },
"vi": { "stringUnit": { "state": "translated", "value": "Tối" } }
}
},
"settings.appearance.light": {
"comment": "Appearance option label for light mode",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Light" } },
"de": { "stringUnit": { "state": "translated", "value": "Hell" } },
"af": { "stringUnit": { "state": "translated", "value": "Lig" } },
"ar": { "stringUnit": { "state": "translated", "value": "فاتح" } },
"es": { "stringUnit": { "state": "translated", "value": "Claro" } },
"fr": { "stringUnit": { "state": "translated", "value": "Clair" } },
"hi": { "stringUnit": { "state": "translated", "value": "लाइट" } },
"id": { "stringUnit": { "state": "translated", "value": "Terang" } },
"it": { "stringUnit": { "state": "translated", "value": "Chiaro" } },
"ja": { "stringUnit": { "state": "translated", "value": "ライト" } },
"ko": { "stringUnit": { "state": "translated", "value": "밝게" } },
"ms": { "stringUnit": { "state": "translated", "value": "Cerah" } },
"nl": { "stringUnit": { "state": "translated", "value": "Licht" } },
"pl": { "stringUnit": { "state": "translated", "value": "Jasny" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Claro" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Claro" } },
"ru": { "stringUnit": { "state": "translated", "value": "Светлая" } },
"sv": { "stringUnit": { "state": "translated", "value": "Ljust" } },
"th": { "stringUnit": { "state": "translated", "value": "สว่าง" } },
"tr": { "stringUnit": { "state": "translated", "value": "Açık" } },
"uk": { "stringUnit": { "state": "translated", "value": "Світла" } },
"vi": { "stringUnit": { "state": "translated", "value": "Sáng" } }
}
},
"settings.appearance.system": {
"comment": "Appearance option label for following the system setting",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "System" } },
"de": { "stringUnit": { "state": "translated", "value": "System" } },
"af": { "stringUnit": { "state": "translated", "value": "Stelsel" } },
"ar": { "stringUnit": { "state": "translated", "value": "النظام" } },
"es": { "stringUnit": { "state": "translated", "value": "Sistema" } },
"fr": { "stringUnit": { "state": "translated", "value": "Système" } },
"hi": { "stringUnit": { "state": "translated", "value": "सिस्टम" } },
"id": { "stringUnit": { "state": "translated", "value": "Sistem" } },
"it": { "stringUnit": { "state": "translated", "value": "Sistema" } },
"ja": { "stringUnit": { "state": "translated", "value": "システム" } },
"ko": { "stringUnit": { "state": "translated", "value": "시스템" } },
"ms": { "stringUnit": { "state": "translated", "value": "Sistem" } },
"nl": { "stringUnit": { "state": "translated", "value": "Systeem" } },
"pl": { "stringUnit": { "state": "translated", "value": "Systemowy" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Sistema" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Sistema" } },
"ru": { "stringUnit": { "state": "translated", "value": "Системная" } },
"sv": { "stringUnit": { "state": "translated", "value": "System" } },
"th": { "stringUnit": { "state": "translated", "value": "ระบบ" } },
"tr": { "stringUnit": { "state": "translated", "value": "Sistem" } },
"uk": { "stringUnit": { "state": "translated", "value": "Системна" } },
"vi": { "stringUnit": { "state": "translated", "value": "Hệ thống" } }
}
},
"settings.appearance.title": {
"comment": "Label for the appearance dropdown in Settings",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Appearance" } },
"de": { "stringUnit": { "state": "translated", "value": "Erscheinungsbild" } },
"af": { "stringUnit": { "state": "translated", "value": "Voorkoms" } },
"ar": { "stringUnit": { "state": "translated", "value": "المظهر" } },
"es": { "stringUnit": { "state": "translated", "value": "Apariencia" } },
"fr": { "stringUnit": { "state": "translated", "value": "Apparence" } },
"hi": { "stringUnit": { "state": "translated", "value": "रूप-रंग" } },
"id": { "stringUnit": { "state": "translated", "value": "Tampilan" } },
"it": { "stringUnit": { "state": "translated", "value": "Aspetto" } },
"ja": { "stringUnit": { "state": "translated", "value": "外観" } },
"ko": { "stringUnit": { "state": "translated", "value": "모양" } },
"ms": { "stringUnit": { "state": "translated", "value": "Penampilan" } },
"nl": { "stringUnit": { "state": "translated", "value": "Weergave" } },
"pl": { "stringUnit": { "state": "translated", "value": "Wygląd" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Aparência" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Aparência" } },
"ru": { "stringUnit": { "state": "translated", "value": "Внешний вид" } },
"sv": { "stringUnit": { "state": "translated", "value": "Utseende" } },
"th": { "stringUnit": { "state": "translated", "value": "ลักษณะที่ปรากฏ" } },
"tr": { "stringUnit": { "state": "translated", "value": "Görünüm" } },
"uk": { "stringUnit": { "state": "translated", "value": "Зовнішній вигляд" } },
"vi": { "stringUnit": { "state": "translated", "value": "Giao diện" } }
}
},
"settings.github.title": {
"comment": "Label for the GitHub external link row in Settings",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Stargaze on GitHub" } },
"de": { "stringUnit": { "state": "translated", "value": "Auf GitHub mit einem Stern markieren" } },
"af": { "stringUnit": { "state": "translated", "value": "Stergaze op GitHub" } },
"ar": { "stringUnit": { "state": "translated", "value": "أضف نجمة على GitHub" } },
"es": { "stringUnit": { "state": "translated", "value": "Dar una estrella en GitHub" } },
"fr": { "stringUnit": { "state": "translated", "value": "Mettre une étoile sur GitHub" } },
"hi": { "stringUnit": { "state": "translated", "value": "GitHub पर स्टार करें" } },
"id": { "stringUnit": { "state": "translated", "value": "Beri bintang di GitHub" } },
"it": { "stringUnit": { "state": "translated", "value": "Metti una stella su GitHub" } },
"ja": { "stringUnit": { "state": "translated", "value": "GitHubでスターをつける" } },
"ko": { "stringUnit": { "state": "translated", "value": "GitHub에서 별표 추가" } },
"ms": { "stringUnit": { "state": "translated", "value": "Beri bintang di GitHub" } },
"nl": { "stringUnit": { "state": "translated", "value": "Ster geven op GitHub" } },
"pl": { "stringUnit": { "state": "translated", "value": "Dodaj gwiazdkę na GitHub" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Dar uma estrela no GitHub" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Dar uma estrela no GitHub" } },
"ru": { "stringUnit": { "state": "translated", "value": "Поставить звезду на GitHub" } },
"sv": { "stringUnit": { "state": "translated", "value": "Stjärnmärk på GitHub" } },
"th": { "stringUnit": { "state": "translated", "value": "กดดาวบน GitHub" } },
"tr": { "stringUnit": { "state": "translated", "value": "GitHub'da yıldız ver" } },
"uk": { "stringUnit": { "state": "translated", "value": "Поставити зірку на GitHub" } },
"vi": { "stringUnit": { "state": "translated", "value": "Gắn sao trên GitHub" } }
}
},
"settings.icloud.info": {
"comment": "Caption text below the iCloud Synchronization row in Settings",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Learn how to enable iCloud on this device." } },
"de": { "stringUnit": { "state": "translated", "value": "Erfahre, wie du iCloud auf diesem Gerät aktivierst." } },
"af": { "stringUnit": { "state": "translated", "value": "Leer hoe om iCloud op hierdie toestel te aktiveer." } },
"ar": { "stringUnit": { "state": "translated", "value": "تعرّف على كيفية تفعيل iCloud على هذا الجهاز." } },
"es": { "stringUnit": { "state": "translated", "value": "Aprende cómo activar iCloud en este dispositivo." } },
"fr": { "stringUnit": { "state": "translated", "value": "Découvrez comment activer iCloud sur cet appareil." } },
"hi": { "stringUnit": { "state": "translated", "value": "इस डिवाइस पर iCloud कैसे सक्षम करें, यह जानें।" } },
"id": { "stringUnit": { "state": "translated", "value": "Pelajari cara mengaktifkan iCloud di perangkat ini." } },
"it": { "stringUnit": { "state": "translated", "value": "Scopri come abilitare iCloud su questo dispositivo." } },
"ja": { "stringUnit": { "state": "translated", "value": "このデバイスでiCloudを有効にする方法を確認する。" } },
"ko": { "stringUnit": { "state": "translated", "value": "이 기기에서 iCloud를 활성화하는 방법을 알아보세요." } },
"ms": { "stringUnit": { "state": "translated", "value": "Ketahui cara mengaktifkan iCloud pada peranti ini." } },
"nl": { "stringUnit": { "state": "translated", "value": "Lees hoe je iCloud inschakelt op dit apparaat." } },
"pl": { "stringUnit": { "state": "translated", "value": "Dowiedz się, jak włączyć iCloud na tym urządzeniu." } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Saiba como ativar o iCloud neste dispositivo." } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Saiba como ativar o iCloud neste dispositivo." } },
"ru": { "stringUnit": { "state": "translated", "value": "Узнайте, как включить iCloud на этом устройстве." } },
"sv": { "stringUnit": { "state": "translated", "value": "Lär dig hur du aktiverar iCloud på den här enheten." } },
"th": { "stringUnit": { "state": "translated", "value": "เรียนรู้วิธีเปิดใช้งาน iCloud บนอุปกรณ์นี้" } },
"tr": { "stringUnit": { "state": "translated", "value": "Bu cihazda iCloud'u nasıl etkinleştireceğinizi öğrenin." } },
"uk": { "stringUnit": { "state": "translated", "value": "Дізнайтеся, як увімкнути iCloud на цьому пристрої." } },
"vi": { "stringUnit": { "state": "translated", "value": "Tìm hiểu cách bật iCloud trên thiết bị này." } }
}
},
"settings.icloud.title": {
"comment": "Label for the iCloud synchronization row in Settings",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "iCloud Synchronization" } },
"de": { "stringUnit": { "state": "translated", "value": "iCloud-Synchronisierung" } },
"af": { "stringUnit": { "state": "translated", "value": "iCloud-sinkronisasie" } },
"ar": { "stringUnit": { "state": "translated", "value": "مزامنة iCloud" } },
"es": { "stringUnit": { "state": "translated", "value": "Sincronización con iCloud" } },
"fr": { "stringUnit": { "state": "translated", "value": "Synchronisation iCloud" } },
"hi": { "stringUnit": { "state": "translated", "value": "iCloud सिंक्रनाइज़ेशन" } },
"id": { "stringUnit": { "state": "translated", "value": "Sinkronisasi iCloud" } },
"it": { "stringUnit": { "state": "translated", "value": "Sincronizzazione iCloud" } },
"ja": { "stringUnit": { "state": "translated", "value": "iCloud同期" } },
"ko": { "stringUnit": { "state": "translated", "value": "iCloud 동기화" } },
"ms": { "stringUnit": { "state": "translated", "value": "Penyegerakan iCloud" } },
"nl": { "stringUnit": { "state": "translated", "value": "iCloud-synchronisatie" } },
"pl": { "stringUnit": { "state": "translated", "value": "Synchronizacja iCloud" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Sincronização com iCloud" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Sincronização com iCloud" } },
"ru": { "stringUnit": { "state": "translated", "value": "Синхронизация iCloud" } },
"sv": { "stringUnit": { "state": "translated", "value": "iCloud-synkronisering" } },
"th": { "stringUnit": { "state": "translated", "value": "การซิงค์ iCloud" } },
"tr": { "stringUnit": { "state": "translated", "value": "iCloud Eşzamanlaması" } },
"uk": { "stringUnit": { "state": "translated", "value": "Синхронізація iCloud" } },
"vi": { "stringUnit": { "state": "translated", "value": "Đồng bộ iCloud" } }
}
},
"settings.title": {
"comment": "Navigation bar title of the Settings screen",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Settings" } },
"de": { "stringUnit": { "state": "translated", "value": "Einstellungen" } },
"af": { "stringUnit": { "state": "translated", "value": "Instellings" } },
"ar": { "stringUnit": { "state": "translated", "value": "الإعدادات" } },
"es": { "stringUnit": { "state": "translated", "value": "Ajustes" } },
"fr": { "stringUnit": { "state": "translated", "value": "Réglages" } },
"hi": { "stringUnit": { "state": "translated", "value": "सेटिंग्स" } },
"id": { "stringUnit": { "state": "translated", "value": "Pengaturan" } },
"it": { "stringUnit": { "state": "translated", "value": "Impostazioni" } },
"ja": { "stringUnit": { "state": "translated", "value": "設定" } },
"ko": { "stringUnit": { "state": "translated", "value": "설정" } },
"ms": { "stringUnit": { "state": "translated", "value": "Tetapan" } },
"nl": { "stringUnit": { "state": "translated", "value": "Instellingen" } },
"pl": { "stringUnit": { "state": "translated", "value": "Ustawienia" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Configurações" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Definições" } },
"ru": { "stringUnit": { "state": "translated", "value": "Настройки" } },
"sv": { "stringUnit": { "state": "translated", "value": "Inställningar" } },
"th": { "stringUnit": { "state": "translated", "value": "การตั้งค่า" } },
"tr": { "stringUnit": { "state": "translated", "value": "Ayarlar" } },
"uk": { "stringUnit": { "state": "translated", "value": "Налаштування" } },
"vi": { "stringUnit": { "state": "translated", "value": "Cài đặt" } }
}
},
"settings.version.title": {
"comment": "Label for the app version row in Settings",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Version" } },
"de": { "stringUnit": { "state": "translated", "value": "Version" } },
"af": { "stringUnit": { "state": "translated", "value": "Weergawe" } },
"ar": { "stringUnit": { "state": "translated", "value": "الإصدار" } },
"es": { "stringUnit": { "state": "translated", "value": "Versión" } },
"fr": { "stringUnit": { "state": "translated", "value": "Version" } },
"hi": { "stringUnit": { "state": "translated", "value": "संस्करण" } },
"id": { "stringUnit": { "state": "translated", "value": "Versi" } },
"it": { "stringUnit": { "state": "translated", "value": "Versione" } },
"ja": { "stringUnit": { "state": "translated", "value": "バージョン" } },
"ko": { "stringUnit": { "state": "translated", "value": "버전" } },
"ms": { "stringUnit": { "state": "translated", "value": "Versi" } },
"nl": { "stringUnit": { "state": "translated", "value": "Versie" } },
"pl": { "stringUnit": { "state": "translated", "value": "Wersja" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "Versão" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "Versão" } },
"ru": { "stringUnit": { "state": "translated", "value": "Версия" } },
"sv": { "stringUnit": { "state": "translated", "value": "Version" } },
"th": { "stringUnit": { "state": "translated", "value": "เวอร์ชัน" } },
"tr": { "stringUnit": { "state": "translated", "value": "Sürüm" } },
"uk": { "stringUnit": { "state": "translated", "value": "Версія" } },
"vi": { "stringUnit": { "state": "translated", "value": "Phiên bản" } }
}
},
"share.format.jpg": {
"comment": "Share format option label for JPEG",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "JPG" } },
"de": { "stringUnit": { "state": "translated", "value": "JPG" } },
"af": { "stringUnit": { "state": "translated", "value": "JPG" } },
"ar": { "stringUnit": { "state": "translated", "value": "JPG" } },
"es": { "stringUnit": { "state": "translated", "value": "JPG" } },
"fr": { "stringUnit": { "state": "translated", "value": "JPG" } },
"hi": { "stringUnit": { "state": "translated", "value": "JPG" } },
"id": { "stringUnit": { "state": "translated", "value": "JPG" } },
"it": { "stringUnit": { "state": "translated", "value": "JPG" } },
"ja": { "stringUnit": { "state": "translated", "value": "JPG" } },
"ko": { "stringUnit": { "state": "translated", "value": "JPG" } },
"ms": { "stringUnit": { "state": "translated", "value": "JPG" } },
"nl": { "stringUnit": { "state": "translated", "value": "JPG" } },
"pl": { "stringUnit": { "state": "translated", "value": "JPG" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "JPG" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "JPG" } },
"ru": { "stringUnit": { "state": "translated", "value": "JPG" } },
"sv": { "stringUnit": { "state": "translated", "value": "JPG" } },
"th": { "stringUnit": { "state": "translated", "value": "JPG" } },
"tr": { "stringUnit": { "state": "translated", "value": "JPG" } },
"uk": { "stringUnit": { "state": "translated", "value": "JPG" } },
"vi": { "stringUnit": { "state": "translated", "value": "JPG" } }
}
},
"share.format.pdf": {
"comment": "Share format option label for PDF",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "PDF" } },
"de": { "stringUnit": { "state": "translated", "value": "PDF" } },
"af": { "stringUnit": { "state": "translated", "value": "PDF" } },
"ar": { "stringUnit": { "state": "translated", "value": "PDF" } },
"es": { "stringUnit": { "state": "translated", "value": "PDF" } },
"fr": { "stringUnit": { "state": "translated", "value": "PDF" } },
"hi": { "stringUnit": { "state": "translated", "value": "PDF" } },
"id": { "stringUnit": { "state": "translated", "value": "PDF" } },
"it": { "stringUnit": { "state": "translated", "value": "PDF" } },
"ja": { "stringUnit": { "state": "translated", "value": "PDF" } },
"ko": { "stringUnit": { "state": "translated", "value": "PDF" } },
"ms": { "stringUnit": { "state": "translated", "value": "PDF" } },
"nl": { "stringUnit": { "state": "translated", "value": "PDF" } },
"pl": { "stringUnit": { "state": "translated", "value": "PDF" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "PDF" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "PDF" } },
"ru": { "stringUnit": { "state": "translated", "value": "PDF" } },
"sv": { "stringUnit": { "state": "translated", "value": "PDF" } },
"th": { "stringUnit": { "state": "translated", "value": "PDF" } },
"tr": { "stringUnit": { "state": "translated", "value": "PDF" } },
"uk": { "stringUnit": { "state": "translated", "value": "PDF" } },
"vi": { "stringUnit": { "state": "translated", "value": "PDF" } }
}
},
"share.format.png": {
"comment": "Share format option label for PNG",
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "PNG" } },
"de": { "stringUnit": { "state": "translated", "value": "PNG" } },
"af": { "stringUnit": { "state": "translated", "value": "PNG" } },
"ar": { "stringUnit": { "state": "translated", "value": "PNG" } },
"es": { "stringUnit": { "state": "translated", "value": "PNG" } },
"fr": { "stringUnit": { "state": "translated", "value": "PNG" } },
"hi": { "stringUnit": { "state": "translated", "value": "PNG" } },
"id": { "stringUnit": { "state": "translated", "value": "PNG" } },
"it": { "stringUnit": { "state": "translated", "value": "PNG" } },
"ja": { "stringUnit": { "state": "translated", "value": "PNG" } },
"ko": { "stringUnit": { "state": "translated", "value": "PNG" } },
"ms": { "stringUnit": { "state": "translated", "value": "PNG" } },
"nl": { "stringUnit": { "state": "translated", "value": "PNG" } },
"pl": { "stringUnit": { "state": "translated", "value": "PNG" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "PNG" } },
"pt-PT": { "stringUnit": { "state": "translated", "value": "PNG" } },
"ru": { "stringUnit": { "state": "translated", "value": "PNG" } },
"sv": { "stringUnit": { "state": "translated", "value": "PNG" } },
"th": { "stringUnit": { "state": "translated", "value": "PNG" } },
"tr": { "stringUnit": { "state": "translated", "value": "PNG" } },
"uk": { "stringUnit": { "state": "translated", "value": "PNG" } },
"vi": { "stringUnit": { "state": "translated", "value": "PNG" } }
}
}
},
"version": "1.0"
}
================================================
FILE: Resources/PrivacyInfo.xcprivacy
================================================
NSPrivacyTracking
NSPrivacyTrackingDomains
NSPrivacyCollectedDataTypes
NSPrivacyAccessedAPITypes
NSPrivacyAccessedAPIType
NSPrivacyAccessedAPICategoryUserDefaults
NSPrivacyAccessedAPITypeReasons
CA92.1
NSPrivacyAccessedAPIType
NSPrivacyAccessedAPICategoryFileTimestamp
NSPrivacyAccessedAPITypeReasons
C617.1
================================================
FILE: SECURITY_POLICY.md
================================================
# Security Policy
## Reporting a Vulnerability
If you believe you have found a security vulnerability in Jottre, please report it privately. **Do not open a public GitHub issue.**
Use [GitHub's private vulnerability reporting](https://github.com/antonlorani/jottre/security/advisories/new) to submit your report. This keeps the details confidential between you and the maintainer until a fix is available.
When reporting, please include:
- A description of the vulnerability and its potential impact.
- Steps to reproduce the issue, including any proof-of-concept code if applicable.
- The affected app version, platform (iOS, iPadOS, macOS), and platform version.
- Any suggested mitigations or fixes, if you have them.
## What to expect
- You will receive an acknowledgement of your report as soon as possible.
- The maintainer will investigate and keep you informed of the progress.
- Once a fix is ready, it will be released and you will be credited in the advisory unless you prefer to remain anonymous.
## Scope
This policy covers the Jottre application and any code in this repository. Vulnerabilities in third-party dependencies should be reported to their respective maintainers, but you are welcome to also notify us so we can update or mitigate.
## Out of scope
- Issues that require physical access to an unlocked device.
- Social engineering of users or maintainers.
- Vulnerabilities in outdated app versions that have already been fixed in a newer release.
Thank you for helping keep Jottre and its users safe.
================================================
FILE: Sources/AppDelegate.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
}
}
================================================
FILE: Sources/ApplicationService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol ApplicationServiceProtocol: Sendable {
func supportsMultipleScenes() -> Bool
func open(url: URL)
func canOpen(url: URL) -> Bool
}
struct ApplicationService: ApplicationServiceProtocol {
private let application: UIApplication
init(application: UIApplication) {
self.application = application
}
func supportsMultipleScenes() -> Bool {
application.supportsMultipleScenes
}
func open(url: URL) {
application.open(url)
}
func canOpen(url: URL) -> Bool {
application.canOpenURL(url)
}
}
================================================
FILE: Sources/BundleService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
protocol BundleServiceProtocol: Sendable {
func shortVersionString() -> String?
}
struct BundleService: BundleServiceProtocol {
private let bundle: Bundle
init(bundle: Bundle) {
self.bundle = bundle
}
func shortVersionString() -> String? {
bundle.infoDictionary?["CFBundleShortVersionString"] as? String
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudImageCell/CloudImageCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class CloudImageCell: UICollectionViewCell, PageCell {
static let reuseIdentifier = "CloudImageCell"
private enum Constants {
static let height = CGFloat(80)
}
private let cloudImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(systemName: "checkmark.icloud.fill"))
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.tintColor = .label
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
assertionFailure("\(#function) has not been implemented")
return nil
}
private func setUpViews() {
contentView.addSubview(cloudImageView)
NSLayoutConstraint.activate([
contentView.heightAnchor.constraint(equalToConstant: Constants.height),
cloudImageView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
cloudImageView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
cloudImageView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
cloudImageView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
])
}
func configure(
viewModel: CloudImageCellViewModel
) {
/* no-op */
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudImageCell/CloudImageCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
final class CloudImageCellViewModel: PageCellViewModel {
func handle(action: PageCellAction) {
/* no-op */
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudImageCell/PageCellItem+cloudImage.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func cloudImage() -> PageCellItem {
PageCellItem(
id: #function,
cellType: CloudImageCell.self,
sizing: .fullWidth(estimatedHeight: 80),
viewModel: CloudImageCellViewModel()
)
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol CloudMigrationCoordinatorProtocol: Coordinator {
func shouldStart() -> Bool
func showInfoAlert(title: String, message: String)
func dismiss()
}
final class CloudMigrationCoordinator: CloudMigrationCoordinatorProtocol {
var onEnd: (() -> Void)?
private var retainedInfoAlertCoordinator: Coordinator?
private let repository: CloudMigrationRepositoryProtocol
private let navigation: Navigation
private let cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryProtocol
private let logger: LoggerProtocol
init(
repository: CloudMigrationRepositoryProtocol,
navigation: Navigation,
cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryProtocol,
logger: LoggerProtocol
) {
self.repository = repository
self.navigation = navigation
self.cloudMigrationViewControllerFactory = cloudMigrationViewControllerFactory
self.logger = logger
}
func shouldStart() -> Bool {
repository.getShouldShowCloudMigration()
}
func start() {
let navigationController = UINavigationController(
rootViewController: cloudMigrationViewControllerFactory.make(
viewModel: CloudMigrationViewModel(
repository: repository,
coordinator: self,
logger: logger
)
)
)
navigation.present(navigationController, animated: true)
}
func showInfoAlert(
title: String,
message: String
) {
let infoAlertCoordinator = InfoAlertCoordinator(
navigation: navigation,
title: title,
message: message
)
retainedInfoAlertCoordinator = infoAlertCoordinator
infoAlertCoordinator.onEnd = { [weak self] in
self?.retainedInfoAlertCoordinator = nil
}
infoAlertCoordinator.start()
}
func dismiss() {
navigation.dismiss(animated: true) { [weak self] in
Task { @MainActor in
self?.onEnd?()
}
}
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@MainActor
protocol CloudMigrationCoordinatorFactoryProtocol: Sendable {
func make(navigation: Navigation) -> CloudMigrationCoordinatorProtocol
}
struct CloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactoryProtocol {
let repository: CloudMigrationRepositoryProtocol
let cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryProtocol
let logger: LoggerProtocol
func make(navigation: Navigation) -> CloudMigrationCoordinatorProtocol {
CloudMigrationCoordinator(
repository: repository,
navigation: navigation,
cloudMigrationViewControllerFactory: cloudMigrationViewControllerFactory,
logger: logger
)
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationJotBusinessModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
struct CloudMigrationJotBusinessModel: Sendable, Hashable {
let name: String
let lastModifiedText: String
let isUbiquitous: Bool
let isDownloaded: Bool
let isDownloading: Bool
private let jotFileInfo: JotFile.Info
init(jotFileInfo: JotFile.Info) {
name = jotFileInfo.name
lastModifiedText =
jotFileInfo.modificationDate.map {
DateFormatter.localizedString(
from: $0,
dateStyle: .long,
timeStyle: .short
)
} ?? String()
self.isUbiquitous = jotFileInfo.ubiquitousInfo != nil
self.isDownloaded = jotFileInfo.ubiquitousInfo?.downloadStatus != .notDownloaded
self.isDownloading = jotFileInfo.ubiquitousInfo?.isDownloading ?? false
self.jotFileInfo = jotFileInfo
}
func toJotFileInfo() -> JotFile.Info {
jotFileInfo
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationJotCell/CloudMigrationJotCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class CloudMigrationJotCell: UICollectionViewCell, PageCell {
static let reuseIdentifier = "CloudMigrationJotCell"
private enum Constants {
static let height = CGFloat(56)
enum Preview {
static let width = CGFloat(70)
}
enum Checbox {
static let size = CGFloat(30)
static func image(isOn: Bool) -> UIImage? {
isOn ? UIImage(systemName: "checkmark.circle.fill") : UIImage(systemName: "circle")
}
}
}
private let previewImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
return imageView
}()
private let separatorLine: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .separator
return view
}()
private let labelContainer: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .body)
label.numberOfLines = 1
return label
}()
private let infoTextLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .caption1)
label.textColor = .secondaryLabel
label.numberOfLines = 1
return label
}()
private let checkboxImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.tintColor = .label
imageView.contentMode = .scaleAspectFit
return imageView
}()
private lazy var downloadActivityIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.translatesAutoresizingMaskIntoConstraints = false
return indicator
}()
private let trailingSlotGuide = UILayoutGuide()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
assertionFailure("\(#function) has not been implemented")
return nil
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasRenderingChange(comparedTo: previousTraitCollection) {
loadPreviewImage()
}
}
private func setUpViews() {
contentView.backgroundColor = .secondarySystemGroupedBackground
contentView.layer.cornerRadius = DesignTokens.CornerRadius.cell
contentView.clipsToBounds = true
contentView.layoutMargins = UIEdgeInsets(
top: DesignTokens.Spacing.xs,
left: DesignTokens.Spacing.xs,
bottom: DesignTokens.Spacing.xs,
right: DesignTokens.Spacing.md
)
contentView.addLayoutGuide(trailingSlotGuide)
contentView.addSubview(previewImageView)
contentView.addSubview(separatorLine)
contentView.addSubview(labelContainer)
labelContainer.addSubview(nameLabel)
labelContainer.addSubview(infoTextLabel)
NSLayoutConstraint.activate([
contentView.heightAnchor.constraint(equalToConstant: Constants.height),
previewImageView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
previewImageView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
previewImageView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
previewImageView.widthAnchor.constraint(equalToConstant: Constants.Preview.width),
separatorLine.leadingAnchor.constraint(equalTo: previewImageView.trailingAnchor),
separatorLine.topAnchor.constraint(equalTo: contentView.topAnchor),
separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
separatorLine.widthAnchor.constraint(equalToConstant: DesignTokens.Length.separator),
trailingSlotGuide.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
trailingSlotGuide.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
trailingSlotGuide.widthAnchor.constraint(equalToConstant: Constants.Checbox.size),
trailingSlotGuide.heightAnchor.constraint(equalToConstant: Constants.Checbox.size),
labelContainer.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
labelContainer.leadingAnchor.constraint(
equalTo: separatorLine.trailingAnchor,
constant: DesignTokens.Spacing.md
),
labelContainer.trailingAnchor.constraint(
lessThanOrEqualTo: trailingSlotGuide.leadingAnchor,
constant: -DesignTokens.Spacing.xs
),
nameLabel.topAnchor.constraint(equalTo: labelContainer.topAnchor),
nameLabel.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor),
nameLabel.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor),
infoTextLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor),
infoTextLabel.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor),
infoTextLabel.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor),
infoTextLabel.bottomAnchor.constraint(equalTo: labelContainer.bottomAnchor),
])
}
private var viewModel: CloudMigrationJotCellViewModel?
private var previewImageTask: Task?
override func prepareForReuse() {
super.prepareForReuse()
previewImageView.image = nil
checkboxImageView.removeFromSuperview()
downloadActivityIndicator.removeFromSuperview()
downloadActivityIndicator.stopAnimating()
}
func configure(
viewModel: CloudMigrationJotCellViewModel
) {
self.viewModel = viewModel
nameLabel.text = viewModel.name
infoTextLabel.text = viewModel.infoText
if viewModel.isDownloading {
contentView.addSubview(downloadActivityIndicator)
NSLayoutConstraint.activate([
downloadActivityIndicator.centerXAnchor.constraint(equalTo: trailingSlotGuide.centerXAnchor),
downloadActivityIndicator.centerYAnchor.constraint(equalTo: trailingSlotGuide.centerYAnchor),
])
downloadActivityIndicator.startAnimating()
} else {
contentView.addSubview(checkboxImageView)
NSLayoutConstraint.activate([
checkboxImageView.centerXAnchor.constraint(equalTo: trailingSlotGuide.centerXAnchor),
checkboxImageView.centerYAnchor.constraint(equalTo: trailingSlotGuide.centerYAnchor),
checkboxImageView.widthAnchor.constraint(equalToConstant: Constants.Checbox.size),
checkboxImageView.heightAnchor.constraint(equalToConstant: Constants.Checbox.size),
])
checkboxImageView.image = Constants.Checbox.image(isOn: viewModel.isCloudCheckboxOn)
}
loadPreviewImage()
}
private func loadPreviewImage() {
guard let viewModel else {
return
}
previewImageTask?.cancel()
previewImageTask = Task { [weak self] in
guard let self else {
return
}
previewImageView.image = await viewModel.getPreviewImage(
userInterfaceStyle: traitCollection.userInterfaceStyle,
displayScale: traitCollection.displayScale
)
}
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationJotCell/CloudMigrationJotCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class CloudMigrationJotCellViewModel: PageCellViewModel {
let name: String
let infoText: String
let isCloudCheckboxOn: Bool
let isDownloading: Bool
let onTap: @Sendable () -> Void
private let cloudMigrationJot: CloudMigrationJotBusinessModel
private let repository: CloudMigrationRepositoryProtocol
init(
cloudMigrationJot: CloudMigrationJotBusinessModel,
repository: CloudMigrationRepositoryProtocol,
onTap: @Sendable @escaping () -> Void
) {
name = cloudMigrationJot.name
infoText = cloudMigrationJot.lastModifiedText
isCloudCheckboxOn = cloudMigrationJot.isUbiquitous
isDownloading = cloudMigrationJot.isDownloading
self.cloudMigrationJot = cloudMigrationJot
self.repository = repository
self.onTap = onTap
}
func handle(action: PageCellAction) {
switch action {
case .tap:
onTap()
}
}
func getPreviewImage(
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
await repository.getPreviewImage(
jotFileInfo: cloudMigrationJot.toJotFileInfo(),
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationJotCell/PageCellItem+cloudMigrationJot.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func cloudMigrationJot(
cloudMigrationJot: CloudMigrationJotBusinessModel,
repository: CloudMigrationRepositoryProtocol,
onTap: @Sendable @escaping () -> Void
) -> PageCellItem {
PageCellItem(
id: cloudMigrationJot,
cellType: CloudMigrationJotCell.self,
sizing: .fullWidth(estimatedHeight: 56),
viewModel: CloudMigrationJotCellViewModel(
cloudMigrationJot: cloudMigrationJot,
repository: repository,
onTap: onTap
)
)
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import UIKit
protocol CloudMigrationRepositoryProtocol: Sendable {
func getJotFiles() -> AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error>
func moveJotFile(
jotFileInfo: JotFile.Info,
shouldBecomeUbiquitous: Bool
) async throws
func getShouldShowCloudMigration() -> Bool
func markCloudMigrationPageDone()
func getPreviewImage(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage?
}
struct CloudMigrationRepository: CloudMigrationRepositoryProtocol {
private let ubiquitousFileService: FileServiceProtocol
private let jotFileService: JotFileServiceProtocol
private let jotFilePreviewImageService: JotFilePreviewImageServiceProtocol
private let defaultsService: DefaultsServiceProtocol
init(
ubiquitousFileService: FileServiceProtocol,
jotFileService: JotFileServiceProtocol,
jotFilePreviewImageService: JotFilePreviewImageServiceProtocol,
defaultsService: DefaultsServiceProtocol
) {
self.ubiquitousFileService = ubiquitousFileService
self.jotFileService = jotFileService
self.jotFilePreviewImageService = jotFilePreviewImageService
self.defaultsService = defaultsService
}
func getJotFiles() -> AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> {
jotFileService
.documentsDirectoryContents()
.map { jotFileInfos in
jotFileInfos
.sorted { lhs, rhs in
if (lhs.ubiquitousInfo != nil) != (rhs.ubiquitousInfo != nil) {
return lhs.ubiquitousInfo == nil
}
return lhs.modificationDate ?? .distantPast > rhs.modificationDate ?? .distantPast
}
.map(CloudMigrationJotBusinessModel.init)
}
.toAsyncThrowingStream()
}
func moveJotFile(
jotFileInfo: JotFile.Info,
shouldBecomeUbiquitous: Bool
) async throws {
try await jotFileService.move(
jotFileInfo: jotFileInfo,
shouldBecomeUbiquitous: shouldBecomeUbiquitous
)
}
func getShouldShowCloudMigration() -> Bool {
if defaultsService.getValue(.hasDoneCloudMigration) == true {
return false
}
let isUbiquitousFileServiceEnabled = ubiquitousFileService.isEnabled()
if let wasICloudEnabled = defaultsService.getValue(.isICloudEnabled) {
return wasICloudEnabled != isUbiquitousFileServiceEnabled
}
if !isUbiquitousFileServiceEnabled {
defaultsService.set(.isICloudEnabled, value: false)
}
return false
}
func markCloudMigrationPageDone() {
defaultsService.set(.hasDoneCloudMigration, value: true)
}
func getPreviewImage(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
do {
let imageData = try await jotFilePreviewImageService.getPreviewImageData(
jotFileInfo: jotFileInfo,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
return UIImage(data: imageData)
} catch {
return nil
}
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationViewControllerFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol CloudMigrationViewControllerFactoryProtocol: Sendable {
func make(viewModel: CloudMigrationViewModel) -> UIViewController
}
struct CloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryProtocol {
let textBarButtonItemFactory: TextBarButtonItemFactory
let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
func make(viewModel: CloudMigrationViewModel) -> UIViewController {
PageViewController(
viewModel: viewModel,
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
)
}
}
================================================
FILE: Sources/CloudMigrationPage/CloudMigrationViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@MainActor
final class CloudMigrationViewModel: PageViewModel, Sendable {
let items: AsyncStream<[PageCellItem]>
private let itemsContinuation: AsyncStream<[PageCellItem]>.Continuation
private let repository: CloudMigrationRepositoryProtocol
private weak var coordinator: CloudMigrationCoordinatorProtocol?
private let logger: LoggerProtocol
private(set) lazy var actions = [
PageCallToActionView.ActionConfiguration(
style: .primary,
title: L10n.Action.done,
icon: nil
) { [weak self] in
self?.didTapDoneButton()
}
]
private var jotsTask: Task?
init(
repository: CloudMigrationRepositoryProtocol,
coordinator: CloudMigrationCoordinatorProtocol,
logger: LoggerProtocol
) {
self.repository = repository
self.coordinator = coordinator
self.logger = logger
(items, itemsContinuation) = AsyncStream.makeStream(
of: [PageCellItem].self,
bufferingPolicy: .bufferingNewest(1)
)
}
func didLoad() {
jotsTask = Task { [weak self] in
guard let self else {
return
}
do {
for try await cloudMigrationJots in repository.getJotFiles() {
handleJots(cloudMigrationJots: cloudMigrationJots)
}
} catch {
logger.error("Failed to observe migration jot files: \(error)")
}
}
}
private func handleJots(cloudMigrationJots: [CloudMigrationJotBusinessModel]) {
var items = [PageCellItem]()
if cloudMigrationJots.isEmpty {
items.append(
contentsOf: [
PageCellItem.cloudImage(),
PageCellItem.pageHeader(
headline: L10n.CloudMigration.title,
subheadline: L10n.CloudMigration.NothingToMigrate.subtitle
),
]
)
} else {
items.append(
PageCellItem.pageHeader(
headline: L10n.CloudMigration.title,
subheadline: L10n.CloudMigration.subtitle
)
)
items.append(
contentsOf: cloudMigrationJots.map { cloudMigrationJot in
PageCellItem.cloudMigrationJot(
cloudMigrationJot: cloudMigrationJot,
repository: repository,
) { [weak self] in
Task { @MainActor in
self?.didTapCloudMigrationJot(cloudMigrationJot: cloudMigrationJot)
}
}
}
)
}
itemsContinuation.yield(items)
}
private func didTapCloudMigrationJot(
cloudMigrationJot: CloudMigrationJotBusinessModel
) {
Task { [weak self] in
do {
try await self?.repository.moveJotFile(
jotFileInfo: cloudMigrationJot.toJotFileInfo(),
shouldBecomeUbiquitous: !cloudMigrationJot.isUbiquitous
)
} catch {
self?.coordinator?.showInfoAlert(
title: L10n.CloudMigration.ErrorAlert.title(cloudMigrationJot.name),
message: error.localizedDescription
)
}
}
}
private func didTapDoneButton() {
repository.markCloudMigrationPageDone()
coordinator?.dismiss()
}
}
================================================
FILE: Sources/CloudMigrationPage/DefaultsKey+hasDoneCloudMigration.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension DefaultsKey {
static var hasDoneCloudMigration: DefaultsKey {
#function
}
}
================================================
FILE: Sources/CloudMigrationPage/DefaultsKey+isICloudEnabled.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension DefaultsKey {
static var isICloudEnabled: DefaultsKey {
#function
}
}
================================================
FILE: Sources/Defaults/DefaultsContinuationStorage.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
final class DefaultsContinuationStorage: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [String: [Any]] = [:]
func add(
_ continuation: AsyncStream.Continuation,
defaultsKey: DefaultsKey
) {
lock.withLock {
continuations[defaultsKey.description, default: []].append(continuation)
}
}
func remove(
_ continuation: AsyncStream.Continuation,
defaultsKey: DefaultsKey
) {
lock.withLock {
continuations[defaultsKey.description]?.removeAll {
($0 as AnyObject) === (continuation as AnyObject)
}
}
}
func continuations(
defaultsKey: DefaultsKey
) -> [AsyncStream.Continuation]? {
lock.withLock {
continuations[defaultsKey.description]?
.compactMap { continuation in
continuation as? AsyncStream.Continuation
}
}
}
}
================================================
FILE: Sources/Defaults/DefaultsKey.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct DefaultsKey: Sendable, CustomStringConvertible,
ExpressibleByStringLiteral
{
let description: String
init(_ key: String) {
description = key
}
init(stringLiteral value: StringLiteralType) {
description = value
}
}
================================================
FILE: Sources/Defaults/DefaultsService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
protocol DefaultsServiceProtocol: Sendable {
func getValue(_ defaultsKey: DefaultsKey) -> T?
func set(_ defaultsKey: DefaultsKey, value: T?)
func getValueStream(_ defaultsKey: DefaultsKey) -> AsyncStream
}
final class DefaultsService: DefaultsServiceProtocol, @unchecked Sendable {
private let userDefaults: UserDefaults
private let continuationStorage = DefaultsContinuationStorage()
init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
}
func getValue(
_ defaultsKey: DefaultsKey
) -> T? {
guard let value = userDefaults.value(forKey: defaultsKey.description) as? String else {
return nil
}
return T(value)
}
func set(
_ defaultsKey: DefaultsKey,
value: T?
) {
let key = defaultsKey.description
userDefaults.setValue(value?.description, forKey: key)
if let continuations = continuationStorage.continuations(defaultsKey: defaultsKey) {
for continuation in continuations {
continuation.yield(value)
}
}
}
func getValueStream(
_ defaultsKey: DefaultsKey
) -> AsyncStream {
AsyncStream { [weak self] continuation in
continuation.yield(self?.getValue(defaultsKey))
self?.continuationStorage.add(
continuation,
defaultsKey: defaultsKey
)
continuation.onTermination = { [weak self] _ in
self?.continuationStorage.remove(
continuation,
defaultsKey: defaultsKey
)
}
}
}
}
================================================
FILE: Sources/DesignTokens.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
enum DesignTokens {
enum Spacing {
static let xs = CGFloat(8)
static let sm = CGFloat(12)
static let md = CGFloat(16)
}
enum CornerRadius {
static let cell = CGFloat(20)
}
enum Length {
static let separator = CGFloat(1)
}
}
================================================
FILE: Sources/DeviceService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol DeviceServiceProtocol: Sendable {
func isIPadOS() -> Bool
}
struct DeviceService: DeviceServiceProtocol {
private let device: UIDevice
init(device: UIDevice) {
self.device = device
}
func isIPadOS() -> Bool {
#if os(iOS)
device.userInterfaceIdiom == .pad
#else
false
#endif
}
}
================================================
FILE: Sources/EditJotPage/EditJotCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol EditJotCoordinatorProtocol: NavigationCoordinator {
func showShareJot(
jotFileInfo: JotFile.Info,
format: ShareFormat,
configurePopoverAnchor: PopoverAnchor?
)
func showRenameAlert(jotFileInfo: JotFile.Info)
func openDeleteJot(jotFileInfo: JotFile.Info)
func openJot(jotFileInfo: JotFile.Info)
func showInFiles(jotFileInfo: JotFile.Info)
func showJotConflictPage(
jotFileInfo: JotFile.Info,
jotFileVersions: [JotFileVersion],
onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void
)
func canGoBack() -> Bool
func goBack()
func showInfoAlert(title: String, message: String)
}
final class EditJotCoordinator: NavigationCoordinator, EditJotCoordinatorProtocol {
private var retainedInfoAlertCoordinator: Coordinator?
private var retainedJotConflictCoordinator: Coordinator?
private var retainedShareJotCoordinator: Coordinator?
private var retainedRenameJotCoordinator: Coordinator?
private var retainedDeleteJotCoordinator: Coordinator?
private var retainedRevealFileCoordinator: Coordinator?
private let navigation: Navigation
private let repository: EditJotRepositoryProtocol
private let editJotViewControllerFactory: EditJotViewControllerFactoryProtocol
private let jotConflictCoordinatorFactory: JotConflictCoordinatorFactoryProtocol
private let renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol
private let deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol
private let shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol
private let revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol
init(
navigation: Navigation,
repository: EditJotRepositoryProtocol,
editJotViewControllerFactory: EditJotViewControllerFactoryProtocol,
jotConflictCoordinatorFactory: JotConflictCoordinatorFactoryProtocol,
renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol,
deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol,
shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol,
revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol
) {
self.navigation = navigation
self.repository = repository
self.editJotViewControllerFactory = editJotViewControllerFactory
self.jotConflictCoordinatorFactory = jotConflictCoordinatorFactory
self.renameJotCoordinatorFactory = renameJotCoordinatorFactory
self.deleteJotCoordinatorFactory = deleteJotCoordinatorFactory
self.shareJotCoordinatorFactory = shareJotCoordinatorFactory
self.revealFileCoordinatorFactory = revealFileCoordinatorFactory
}
func shouldHandle(url: URL) -> Bool {
guard EditJotURL(url: url) != nil else {
return false
}
return true
}
func handle(url: URL) -> [UIViewController] {
guard
let editJotURL = EditJotURL(url: url),
let jotFileInfo = JotFile.Info(
url: editJotURL.fileURL,
modificationDate: nil,
ubiquitousInfo: repository.ubiquitousInfo(url: editJotURL.fileURL)
)
else {
return []
}
return [
editJotViewControllerFactory.make(
jotFileInfo: jotFileInfo,
coordinator: self
)
]
}
func showShareJot(
jotFileInfo: JotFile.Info,
format: ShareFormat,
configurePopoverAnchor: PopoverAnchor?
) {
let coordinator = shareJotCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
format: format,
navigation: navigation,
configurePopoverAnchor: configurePopoverAnchor
)
retainedShareJotCoordinator = coordinator
coordinator.onEnd = { [weak self] in
self?.retainedShareJotCoordinator = nil
}
coordinator.start()
}
func showRenameAlert(jotFileInfo: JotFile.Info) {
let coordinator = renameJotCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
navigation: navigation
) { [weak self] renameJotFileInfo in
Task { @MainActor in
self?.openJot(jotFileInfo: renameJotFileInfo)
}
}
retainedRenameJotCoordinator = coordinator
coordinator.onEnd = { [weak self] in
self?.retainedRenameJotCoordinator = nil
}
coordinator.start()
}
func openDeleteJot(jotFileInfo: JotFile.Info) {
let deleteJotCoordinator = deleteJotCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
navigation: navigation
)
retainedDeleteJotCoordinator = deleteJotCoordinator
deleteJotCoordinator.onEnd = { [weak self] in
self?.retainedDeleteJotCoordinator = nil
self?.goBack()
}
deleteJotCoordinator.start()
}
func openJot(jotFileInfo: JotFile.Info) {
navigation.open(url: EditJotURL(jotFileInfo: jotFileInfo))
}
func showInFiles(jotFileInfo: JotFile.Info) {
let revealFileCoordinator = revealFileCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
navigation: navigation
)
retainedRevealFileCoordinator = revealFileCoordinator
revealFileCoordinator.onEnd = { [weak self] in
self?.retainedRevealFileCoordinator = nil
}
revealFileCoordinator.start()
}
func showJotConflictPage(
jotFileInfo: JotFile.Info,
jotFileVersions: [JotFileVersion],
onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void
) {
let jotConflictCoordinator = jotConflictCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
jotFileVersions: jotFileVersions,
navigation: navigation,
onResult: onResult
)
retainedJotConflictCoordinator = jotConflictCoordinator
jotConflictCoordinator.onEnd = { [weak self] in
self?.retainedJotConflictCoordinator = nil
}
jotConflictCoordinator.start()
}
func canGoBack() -> Bool {
navigation.getViewControllers().count > 1
}
func goBack() {
navigation.popViewController(animated: true)
}
func showInfoAlert(
title: String,
message: String
) {
let infoAlertCoordinator = InfoAlertCoordinator(
navigation: navigation,
title: title,
message: message
)
retainedInfoAlertCoordinator = infoAlertCoordinator
infoAlertCoordinator.onEnd = { [weak self] in
self?.retainedInfoAlertCoordinator = nil
}
infoAlertCoordinator.start()
}
}
================================================
FILE: Sources/EditJotPage/EditJotCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@MainActor
protocol EditJotCoordinatorFactoryProtocol {
func make(navigation: Navigation) -> NavigationCoordinator
}
struct EditJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol {
let repository: EditJotRepositoryProtocol
let editJotViewControllerFactory: EditJotViewControllerFactoryProtocol
let jotConflictCoordinatorFactory: JotConflictCoordinatorFactory
let renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol
let deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol
let shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol
let revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol
func make(navigation: Navigation) -> NavigationCoordinator {
EditJotCoordinator(
navigation: navigation,
repository: repository,
editJotViewControllerFactory: editJotViewControllerFactory,
jotConflictCoordinatorFactory: jotConflictCoordinatorFactory,
renameJotCoordinatorFactory: renameJotCoordinatorFactory,
deleteJotCoordinatorFactory: deleteJotCoordinatorFactory,
shareJotCoordinatorFactory: shareJotCoordinatorFactory,
revealFileCoordinatorFactory: revealFileCoordinatorFactory
)
}
}
================================================
FILE: Sources/EditJotPage/EditJotRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@preconcurrency import PencilKit
protocol EditJotRepositoryProtocol: Sendable {
func ubiquitousInfo(url: URL) -> UbiquitousInfo?
func readDrawing(jotFileInfo: JotFile.Info) async throws -> (drawing: PKDrawing, width: CGFloat)
func writeDrawing(jotFileInfo: JotFile.Info, drawing: PKDrawing) async throws
func getConflictingVersions(jotFileInfo: JotFile.Info) -> [JotFileVersion]?
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info
}
struct EditJotRepository: EditJotRepositoryProtocol {
private let ubiquitousFileService: FileServiceProtocol
private let jotFileService: JotFileServiceProtocol
private let jotFileConflictService: JotFileConflictServiceProtocol
init(
ubiquitousFileService: FileServiceProtocol,
jotFileService: JotFileServiceProtocol,
jotFileConflictService: JotFileConflictServiceProtocol
) {
self.ubiquitousFileService = ubiquitousFileService
self.jotFileService = jotFileService
self.jotFileConflictService = jotFileConflictService
}
func ubiquitousInfo(url: URL) -> UbiquitousInfo? {
ubiquitousFileService.ubiquitousInfo(url: url)
}
func readDrawing(jotFileInfo: JotFile.Info) async throws -> (drawing: PKDrawing, width: CGFloat) {
try? FileManager.default.startDownloadingUbiquitousItem(at: jotFileInfo.url)
let file = try jotFileService.readJotFile(jotFileInfo: jotFileInfo)
let drawing = try PKDrawing(data: file.jot.drawing)
return (
drawing: drawing,
width: file.jot.width
)
}
func writeDrawing(jotFileInfo: JotFile.Info, drawing: PKDrawing) async throws {
let jot = Jot.makeEmpty()
let jotFile = JotFile(
info: jotFileInfo,
jot: Jot(
version: jot.version,
drawing: drawing.dataRepresentation(),
width: jot.width
)
)
try jotFileService.write(jotFile: jotFile)
}
func getConflictingVersions(jotFileInfo: JotFile.Info) -> [JotFileVersion]? {
jotFileConflictService.getConfictingVersions(jotFileInfo: jotFileInfo)
}
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info {
try jotFileService.duplicate(jotFileInfo: jotFileInfo)
}
}
================================================
FILE: Sources/EditJotPage/EditJotURL.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
struct EditJotURL: URLConvertible {
static let path = "/jots/edit"
let path: String = Self.path
var queryItems: [URLQueryItem] {
[
URLQueryItem(
name: "fileURL",
value: fileURL.absoluteString
)
]
}
let fileURL: URL
init(jotFileInfo: JotFile.Info) {
fileURL = jotFileInfo.url
}
init?(url: URL) {
guard
url.path.hasPrefix(EditJotURL.path),
let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false),
let fileURLValue = urlComponents.queryItems?.first(where: { $0.name == "fileURL" })?.value,
let fileURL = URL(string: fileURLValue)
else {
return nil
}
self.fileURL = fileURL
}
}
================================================
FILE: Sources/EditJotPage/EditJotViewController.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@preconcurrency import PencilKit
import UIKit
final class EditJotViewController: UIViewController {
private enum Constants {
enum CanvasView {
static let maximumZoomScale = CGFloat(3)
static let bottomFreespace = CGFloat(500)
}
}
#if !targetEnvironment(macCatalyst)
private lazy var toolPicker = PKToolPicker()
#endif
private lazy var canvasView: PKCanvasView = {
let canvasView = PKCanvasView()
canvasView.delegate = self
canvasView.translatesAutoresizingMaskIntoConstraints = false
canvasView.drawingPolicy = .default
canvasView.maximumZoomScale = Constants.CanvasView.maximumZoomScale
canvasView.bounces = false
canvasView.contentInsetAdjustmentBehavior = .always
return canvasView
}()
private var drawingWidth = CGFloat.zero
private lazy var swipeBackGesture: UIScreenEdgePanGestureRecognizer = {
let gesture = UIScreenEdgePanGestureRecognizer(
target: self,
action: #selector(handleSwipeBack)
)
gesture.edges = .left
gesture.isEnabled = false
return gesture
}()
private var isEditingTask: Task?
private var drawingTask: Task?
private var backButtonTask: Task?
private let viewModel: EditJotViewModel
private let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
init(
viewModel: EditJotViewModel,
symbolBarButtonItemFactory: SymbolBarButtonItemFactory
) {
self.viewModel = viewModel
self.symbolBarButtonItemFactory = symbolBarButtonItemFactory
super.init(nibName: nil, bundle: nil)
isEditingTask = Task { @MainActor [weak self] in
for await isEditing in viewModel.isEditing {
self?.handleEditing(isEditing: isEditing)
}
}
drawingTask = Task { @MainActor [weak self] in
for await drawing in viewModel.drawing {
guard let self else {
return
}
drawingWidth = drawing.width
canvasView.drawing = drawing.value
if canvasView.superview == nil {
setUpCanvasView()
}
}
}
backButtonTask = Task { @MainActor [weak self] in
for await showsBackButton in viewModel.showsBackButton {
self?.handleBackButton(showsBackButton: showsBackButton)
}
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
assertionFailure("\(#function) has not been implemented")
return nil
}
deinit {
isEditingTask?.cancel()
drawingTask?.cancel()
backButtonTask?.cancel()
}
override func viewDidLoad() {
setUpNavigationBar()
setUpViews()
super.viewDidLoad()
viewModel.didLoad()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutCanvasContent()
}
private func setUpNavigationBar() {
navigationItem.largeTitleDisplayMode = .never
navigationItem.title = viewModel.title
}
private func handleBackButton(showsBackButton: Bool) {
guard showsBackButton else {
return
}
navigationItem.leftBarButtonItem = symbolBarButtonItemFactory.make(
symbolName: "chevron.left",
primaryAction: .action(
UIAction { [weak self] _ in
self?.viewModel.didTapBackButton()
}
)
)
}
private func setUpViews() {
view.backgroundColor = .adaptiveBlackWhite
view.addGestureRecognizer(swipeBackGesture)
#if !targetEnvironment(macCatalyst)
toolPicker.addObserver(canvasView)
toolPicker.setVisible(true, forFirstResponder: canvasView)
#endif
}
@objc
private func handleSwipeBack(_ gesture: UIScreenEdgePanGestureRecognizer) {
guard gesture.state == .ended else {
return
}
viewModel.didTapBackButton()
}
private func layoutCanvasContent() {
guard drawingWidth > 0 else {
return
}
let scale = canvasView.bounds.width / drawingWidth
canvasView.minimumZoomScale = scale
canvasView.zoomScale = scale
let drawingMaxY =
if canvasView.drawing.bounds.isNull {
CGFloat.zero
} else {
canvasView.drawing.bounds.maxY + Constants.CanvasView.bottomFreespace
}
canvasView.contentSize = CGSize(
width: canvasView.bounds.width,
height: max(canvasView.bounds.height, drawingMaxY * scale)
)
}
private func setUpCanvasView() {
view.addSubview(canvasView)
NSLayoutConstraint.activate([
canvasView.topAnchor.constraint(equalTo: view.topAnchor),
canvasView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
canvasView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
canvasView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func handleEditing(isEditing: Bool?) {
let rightNavigationBarButtonItems = makeRightNavigationBarButtonItems(isEditing: isEditing)
if let isEditing, isEditing {
canvasView.becomeFirstResponder()
swipeBackGesture.isEnabled = false
if #available(iOS 18.0, *) {
canvasView.isDrawingEnabled = true
} else {
canvasView.isUserInteractionEnabled = true
}
} else {
canvasView.resignFirstResponder()
swipeBackGesture.isEnabled = true
if #available(iOS 18.0, *) {
canvasView.isDrawingEnabled = false
} else {
canvasView.isUserInteractionEnabled = false
}
}
// There's a bug in the stack layouting of ``UINavigationItem`` which, if placing a single item,
// causes this single item to stretch across the entire navigation bar.
if let firstNavigationBarItem = rightNavigationBarButtonItems.first, rightNavigationBarButtonItems.count == 1 {
navigationItem.setRightBarButton(firstNavigationBarItem, animated: false)
} else {
navigationItem.setRightBarButtonItems(rightNavigationBarButtonItems, animated: false)
}
}
private func makeRightNavigationBarButtonItems(isEditing: Bool?) -> [UIBarButtonItem] {
var barButtonItems = [UIBarButtonItem]()
weak var moreBarButtonItemRef: UIBarButtonItem?
let moreBarButtonItem = symbolBarButtonItemFactory.make(
symbolName: "ellipsis",
primaryAction: .menu(
.make(
jotMenuConfigurations: viewModel.menuConfigurations.make(popoverAnchorProvider: {
guard let barButtonItem = moreBarButtonItemRef else {
return nil
}
return { $0.barButtonItem = barButtonItem }
})
)
)
)
moreBarButtonItemRef = moreBarButtonItem
barButtonItems.append(moreBarButtonItem)
if let isEditing {
barButtonItems.append(
symbolBarButtonItemFactory
.make(
symbolName: isEditing ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle",
primaryAction: .action(
UIAction { [weak self] _ in
self?.viewModel.didTapToggleEditingButton(isEditing: isEditing)
}
)
)
)
}
return barButtonItems
}
}
extension EditJotViewController: PKCanvasViewDelegate {
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
viewModel.didChangeDrawing(canvasView.drawing)
}
}
================================================
FILE: Sources/EditJotPage/EditJotViewControllerFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol EditJotViewControllerFactoryProtocol: Sendable {
func make(
jotFileInfo: JotFile.Info,
coordinator: EditJotCoordinatorProtocol
) -> UIViewController
}
struct EditJotViewControllerFactory: EditJotViewControllerFactoryProtocol {
let repository: EditJotRepositoryProtocol
let menuConfigurationFactory: JotMenuConfigurationFactory
let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
let logger: LoggerProtocol
func make(
jotFileInfo: JotFile.Info,
coordinator: EditJotCoordinatorProtocol,
) -> UIViewController {
EditJotViewController(
viewModel: EditJotViewModel(
jotFileInfo: jotFileInfo,
repository: repository,
coordinator: coordinator,
menuConfigurationFactory: menuConfigurationFactory,
logger: logger
),
symbolBarButtonItemFactory: symbolBarButtonItemFactory
)
}
}
================================================
FILE: Sources/EditJotPage/EditJotViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@preconcurrency import PencilKit
@MainActor
final class EditJotViewModel: Sendable {
struct Drawing: Sendable {
let value: PKDrawing
let width: CGFloat
}
private(set) lazy var menuConfigurations = menuConfigurationFactory.make(
onShare: { [weak self] format, configurePopoverAnchor in
Task { @MainActor [weak self] in
guard let self else {
return
}
self.coordinator?.showShareJot(
jotFileInfo: self.jotFileInfo,
format: format,
configurePopoverAnchor: configurePopoverAnchor
)
}
},
onRename: { [weak self] in
Task { @MainActor [weak self] in
guard let self else {
return
}
self.coordinator?.showRenameAlert(jotFileInfo: self.jotFileInfo)
}
},
onDuplicate: { [weak self] in
Task { @MainActor [weak self] in
guard let self else {
return
}
self.didTapDuplicateJot(jotFileInfo: self.jotFileInfo)
}
},
onDelete: { [weak self] in
Task { @MainActor [weak self] in
guard let self else {
return
}
self.coordinator?.openDeleteJot(jotFileInfo: self.jotFileInfo)
}
},
onShowInFiles: { [weak self] in
Task { @MainActor [weak self] in
guard let self else {
return
}
self.coordinator?.showInFiles(jotFileInfo: self.jotFileInfo)
}
}
)
var title: String {
jotFileInfo.name
}
let drawing: AsyncStream
private let drawingContinuation: AsyncStream.Continuation
let isEditing: AsyncStream
private let isEditingContinuation: AsyncStream.Continuation
let showsBackButton: AsyncStream
private let showsBackButtonContinuation: AsyncStream.Continuation
private var drawingUpdateTask: Task?
private let drawingUpdateContinuation: AsyncStream.Continuation
private var loadingTask: Task?
private let jotFileInfo: JotFile.Info
private let repository: EditJotRepositoryProtocol
private weak var coordinator: EditJotCoordinatorProtocol?
private let menuConfigurationFactory: JotMenuConfigurationFactory
private let logger: LoggerProtocol
init(
jotFileInfo: JotFile.Info,
repository: EditJotRepositoryProtocol,
coordinator: EditJotCoordinatorProtocol,
menuConfigurationFactory: JotMenuConfigurationFactory,
logger: LoggerProtocol
) {
self.jotFileInfo = jotFileInfo
self.coordinator = coordinator
self.repository = repository
self.menuConfigurationFactory = menuConfigurationFactory
self.logger = logger
(isEditing, isEditingContinuation) = AsyncStream.makeStream(
of: Bool?.self,
bufferingPolicy: .bufferingNewest(1)
)
(drawing, drawingContinuation) = AsyncStream.makeStream(
of: Drawing.self,
bufferingPolicy: .bufferingNewest(1)
)
(showsBackButton, showsBackButtonContinuation) = AsyncStream.makeStream(
of: Bool.self,
bufferingPolicy: .bufferingNewest(1)
)
#if targetEnvironment(macCatalyst)
isEditingContinuation.yield(nil)
#else
isEditingContinuation.yield(false)
#endif
let (drawingUpdate, drawingUpdateContinuation) = AsyncStream.makeStream(
of: PKDrawing.self,
bufferingPolicy: .bufferingNewest(1)
)
self.drawingUpdateContinuation = drawingUpdateContinuation
drawingUpdateTask = Task { [logger] in
for await drawing in drawingUpdate.dropFirst().debounce(for: 0.3) {
do {
try await repository.writeDrawing(jotFileInfo: jotFileInfo, drawing: drawing)
} catch {
logger.error("Failed to write drawing: \(error)")
}
}
}
}
func didLoad() {
showsBackButtonContinuation.yield(coordinator?.canGoBack() ?? false)
if let jotFileVersions = repository.getConflictingVersions(jotFileInfo: jotFileInfo) {
coordinator?.showJotConflictPage(
jotFileInfo: jotFileInfo,
jotFileVersions: jotFileVersions
) { [weak self] result in
Task { @MainActor in
switch result {
case .keepAll:
self?.coordinator?.goBack()
case let .keep(jotFileInfo):
self?.coordinator?.openJot(jotFileInfo: jotFileInfo)
}
}
}
} else {
loadingTask = Task { [weak self] in
guard let self else {
return
}
do {
let (drawing, width) = try await repository.readDrawing(jotFileInfo: jotFileInfo)
drawingContinuation.yield(Drawing(value: drawing, width: width))
} catch {
logger.error("Failed to read drawing: \(error)")
}
}
}
}
func didTapToggleEditingButton(isEditing: Bool) {
isEditingContinuation.yield(!isEditing)
}
func didChangeDrawing(_ drawing: PKDrawing) {
drawingUpdateContinuation.yield(drawing)
}
func didTapBackButton() {
if let jotFileVersions = repository.getConflictingVersions(jotFileInfo: jotFileInfo) {
coordinator?.showJotConflictPage(
jotFileInfo: jotFileInfo,
jotFileVersions: jotFileVersions
) { [weak self] _ in
Task { @MainActor in
self?.coordinator?.goBack()
}
}
} else {
coordinator?.goBack()
}
}
private func didTapDuplicateJot(jotFileInfo: JotFile.Info) {
do {
let duplicatedJotFileInfo = try repository.duplicate(jotFileInfo: jotFileInfo)
coordinator?.openJot(jotFileInfo: duplicatedJotFileInfo)
} catch {
coordinator?.showInfoAlert(
title: L10n.Jots.Duplicate.Error.generic(jotFileInfo.name),
message: error.localizedDescription
)
}
}
deinit {
drawingUpdateTask?.cancel()
}
}
================================================
FILE: Sources/EnableCloudPage/EnableCloudCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol EnableCloudCoordinatorProtocol: Coordinator {
func openLearnHowToEnable()
func dismiss()
}
final class EnableCloudCoordinator: Coordinator, EnableCloudCoordinatorProtocol {
var onEnd: (() -> Void)?
private let navigation: Navigation
private let enableCloudViewControllerFactory: EnableCloudViewControllerFactoryProtocol
init(
navigation: Navigation,
enableCloudViewControllerFactory: EnableCloudViewControllerFactoryProtocol
) {
self.navigation = navigation
self.enableCloudViewControllerFactory = enableCloudViewControllerFactory
}
func start() {
let navigationController = UINavigationController(
rootViewController: enableCloudViewControllerFactory.make(coordinator: self)
)
navigation.present(navigationController, animated: true)
}
func openLearnHowToEnable() {
navigation.openExternal(url: EnableICloudSupportURL().toURL())
}
func dismiss() {
navigation.dismiss(
animated: true,
completion: { [weak self] in
Task { @MainActor in
self?.onEnd?()
}
}
)
}
}
================================================
FILE: Sources/EnableCloudPage/EnableCloudCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@MainActor
protocol EnableCloudCoordinatorFactoryProtocol: Sendable {
func make(navigation: Navigation) -> Coordinator
}
struct EnableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryProtocol {
let enableCloudViewControllerFactory: EnableCloudViewControllerFactoryProtocol
func make(navigation: Navigation) -> Coordinator {
EnableCloudCoordinator(
navigation: navigation,
enableCloudViewControllerFactory: enableCloudViewControllerFactory
)
}
}
================================================
FILE: Sources/EnableCloudPage/EnableCloudViewControllerFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol EnableCloudViewControllerFactoryProtocol: Sendable {
func make(coordinator: EnableCloudCoordinatorProtocol) -> UIViewController
}
struct EnableCloudViewControllerFactory: EnableCloudViewControllerFactoryProtocol {
let textBarButtonItemFactory: TextBarButtonItemFactory
let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
func make(coordinator: EnableCloudCoordinatorProtocol) -> UIViewController {
PageViewController(
viewModel: EnableCloudViewModel(
coordinator: coordinator
),
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
)
}
}
================================================
FILE: Sources/EnableCloudPage/EnableCloudViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
final class EnableCloudViewModel: PageViewModel, Sendable {
let rightNavigationItems: AsyncStream<[PageNavigationItem]>
private let rightNavigationItemsContinuation: AsyncStream<[PageNavigationItem]>.Continuation
let items: AsyncStream<[PageCellItem]>
private let itemsContinuation: AsyncStream<[PageCellItem]>.Continuation
private weak var coordinator: EnableCloudCoordinatorProtocol?
private(set) lazy var actions = [
PageCallToActionView.ActionConfiguration(
style: .primary,
title: L10n.EnableCloud.Action.learnHowToEnable,
icon: "arrow.up.forward"
) { [weak self] in
self?.didTapLearnHowToEnable()
}
]
init(coordinator: EnableCloudCoordinatorProtocol) {
self.coordinator = coordinator
(items, itemsContinuation) = AsyncStream.makeStream(
of: [PageCellItem].self,
bufferingPolicy: .bufferingNewest(1)
)
itemsContinuation.yield([
PageCellItem.pageHeader(
headline: L10n.EnableCloud.title,
subheadline: L10n.EnableCloud.subtitle
),
PageCellItem.featureRow(
systemImageName: "macbook.and.iphone",
text: L10n.EnableCloud.Feature.sync
),
PageCellItem.featureRow(
systemImageName: "person.3.fill",
text: L10n.EnableCloud.Feature.share
),
])
(rightNavigationItems, rightNavigationItemsContinuation) = AsyncStream.makeStream(
of: [PageNavigationItem].self,
bufferingPolicy: .bufferingNewest(1)
)
rightNavigationItemsContinuation.yield([
PageNavigationItem.symbol(
systemImageName: "xmark"
) { [weak coordinator] in
Task { @MainActor in
coordinator?.dismiss()
}
}
])
}
private func didTapCloseButton() {
coordinator?.dismiss()
}
private func didTapLearnHowToEnable() {
coordinator?.openLearnHowToEnable()
}
}
================================================
FILE: Sources/EnableCloudPage/FeatureRow/FeatureRowCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class FeatureRowCell: UICollectionViewCell, PageCell {
static let reuseIdentifier = "FeatureRowCell"
private enum Constants {
enum Icon {
static let size = CGFloat(28)
}
}
private let iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.tintColor = .label
imageView.contentMode = .scaleAspectFit
return imageView
}()
private let textLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .body)
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("\(#function) has not been implemented")
}
private func setUpViews() {
contentView.backgroundColor = .secondarySystemGroupedBackground
contentView.layer.cornerRadius = DesignTokens.CornerRadius.cell
contentView.clipsToBounds = true
contentView.addSubview(iconImageView)
contentView.addSubview(textLabel)
NSLayoutConstraint.activate([
iconImageView.leadingAnchor.constraint(
equalTo: contentView.leadingAnchor,
constant: DesignTokens.Spacing.md
),
iconImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: Constants.Icon.size),
iconImageView.heightAnchor.constraint(equalToConstant: Constants.Icon.size),
textLabel.leadingAnchor.constraint(
equalTo: iconImageView.trailingAnchor,
constant: DesignTokens.Spacing.md
),
textLabel.trailingAnchor.constraint(
equalTo: contentView.trailingAnchor,
constant: -DesignTokens.Spacing.md
),
textLabel.topAnchor.constraint(
equalTo: contentView.topAnchor,
constant: DesignTokens.Spacing.md
),
textLabel.bottomAnchor.constraint(
equalTo: contentView.bottomAnchor,
constant: -DesignTokens.Spacing.md
),
])
}
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attributes = super.preferredLayoutAttributesFitting(layoutAttributes)
let size = contentView.systemLayoutSizeFitting(
CGSize(width: attributes.frame.width, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
attributes.frame.size.height = size.height
return attributes
}
func configure(viewModel: FeatureRowCellViewModel) {
iconImageView.image = UIImage(systemName: viewModel.systemImageName)
textLabel.text = viewModel.text
}
}
================================================
FILE: Sources/EnableCloudPage/FeatureRow/FeatureRowCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
final class FeatureRowCellViewModel: PageCellViewModel {
let systemImageName: String
let text: String
init(
systemImageName: String,
text: String
) {
self.systemImageName = systemImageName
self.text = text
}
func handle(action: PageCellAction) {
/* no-op */
}
}
================================================
FILE: Sources/EnableCloudPage/FeatureRow/PageCellItem+featureRow.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func featureRow(
systemImageName: String,
text: String
) -> PageCellItem {
PageCellItem(
id: systemImageName + text,
cellType: FeatureRowCell.self,
sizing: .fullWidth(estimatedHeight: 44),
viewModel: FeatureRowCellViewModel(
systemImageName: systemImageName,
text: text
)
)
}
}
================================================
FILE: Sources/FileConflictService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
protocol FileConflictServiceProtocol: Sendable {
func getConflictingVersions(fileURL: URL) -> [NSFileVersion]?
func resolveVersionConflicts(
fileURL: URL,
resolvedVersions: [URL]
) throws
func copyVersionToTemporary(
fileURL: URL,
versionURL: URL
) throws -> URL?
}
struct FileConflictService: FileConflictServiceProtocol {
enum Failure: Error {
case couldNotCoordinateWrite
}
nonisolated(unsafe) private let fileManager: FileManager
init(fileManager: FileManager) {
self.fileManager = fileManager
}
func getConflictingVersions(fileURL: URL) -> [NSFileVersion]? {
NSFileVersion.unresolvedConflictVersionsOfItem(at: fileURL)
}
func resolveVersionConflicts(
fileURL: URL,
resolvedVersions: [URL]
) throws {
guard
let unresolvedConflicts = getConflictingVersions(fileURL: fileURL),
!resolvedVersions.isEmpty
else {
return
}
let directory = fileURL.deletingLastPathComponent()
let name = fileURL.deletingPathExtension().lastPathComponent
for (index, versionURL) in resolvedVersions.dropFirst().enumerated() {
guard let version = unresolvedConflicts.first(where: { $0.url == versionURL }) else {
continue
}
let copyName = "\(name) (\(index + 2))"
let copyURL =
directory
.appendingPathComponent(copyName, isDirectory: false)
.appendingPathExtension(fileURL.pathExtension)
try coordinateWrite(fileURL: copyURL) { url in
try version.replaceItem(at: url, options: [])
}
}
try coordinateWrite(fileURL: fileURL) { url in
if let first = resolvedVersions.first, first != fileURL,
let version = unresolvedConflicts.first(where: { $0.url == first })
{
try version.replaceItem(at: url, options: [])
}
for conflictingVersion in unresolvedConflicts {
conflictingVersion.isResolved = true
}
try NSFileVersion.removeOtherVersionsOfItem(at: url)
}
}
func copyVersionToTemporary(
fileURL: URL,
versionURL: URL
) throws -> URL? {
guard
let versions = NSFileVersion.unresolvedConflictVersionsOfItem(at: fileURL),
let version = versions.first(where: { $0.url == versionURL })
else {
return nil
}
let tmpURL = fileManager.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileURL.pathExtension)
try coordinateWrite(fileURL: tmpURL) { url in
try version.replaceItem(at: url, options: [])
}
return tmpURL
}
private func coordinateWrite(
fileURL: URL,
accessor: (URL) throws -> Void
) throws {
let coordinator = NSFileCoordinator()
var coordinatorError: NSError?
var result: Result?
coordinator.coordinate(
writingItemAt: fileURL,
options: .forReplacing,
error: &coordinatorError
) { url in
result = Result(catching: {
try accessor(url)
})
}
if let coordinatorError {
throw coordinatorError
}
guard let result else {
throw Failure.couldNotCoordinateWrite
}
try result.get()
}
}
================================================
FILE: Sources/FileService/FileServiceProtocol.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
protocol FileServiceProtocol: Sendable {
/// Whether the ``FileService`` is available for file-system interaction.
func isEnabled() -> Bool
/// Materializes the documents directory so it is visible to the user.
func initializeDocumentsDirectory() async throws
/// Returns the path to the apps documents directory.
func documentsDirectory() async throws -> URL?
/// A temporary directory, suitable for loosy file persistence.
func temporaryDirectory() -> URL
/// Returns the contents of the given directories.
func listContents(
directory: URL,
properties: [URLResourceKey]
) throws -> [URL]
/// The ubiquitous info of the given file path if applicable.
func ubiquitousInfo(url: URL) -> UbiquitousInfo?
/// Triggers a download of the ubiquitous item at the given URL.
func startDownload(fileURL: URL) throws
/// A stream that fires once the contents within the specified directory changes.
///
/// - NOTE: Only recognizes file changes at the first level depth.
///
func directoryChanges(directory: URL) -> AsyncStream
/// Returns the contents of a file.
func readFile(fileURL: URL) throws -> Data
/// Writes the contents of a file.
func writeFile(fileURL: URL, data: Data) throws
/// Whether the file is present on the file system.
func fileExists(fileURL: URL) -> Bool
/// Removes a file from the file-system.
func removeFile(fileURL: URL) throws
/// Moves a file from its current place to a new place.
func moveFile(fileURL: URL, newFileURL: URL) throws
/// Creates a copy of a file at a given destination.
func duplicateFile(fileURL: URL) throws -> URL
}
================================================
FILE: Sources/FileService/LocalFileService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
struct LocalFileService: FileServiceProtocol {
enum Failure: Error {
case couldNotReadFileContents
case couldNotWriteFileContents
}
nonisolated(unsafe) private let fileManager: FileManager
init(fileManager: FileManager) {
self.fileManager = fileManager
}
func isEnabled() -> Bool {
true
}
func initializeDocumentsDirectory() async throws {
guard let directory = try await documentsDirectory() else {
return
}
let placeholder = directory.appendingPathComponent(".jottre", isDirectory: false)
guard !fileExists(fileURL: placeholder) else {
return
}
try writeFile(fileURL: placeholder, data: Data("Hello, World!".utf8))
}
func documentsDirectory() async throws -> URL? {
guard let directory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
var isDirectory = ObjCBool(true)
if !fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
return directory
}
func temporaryDirectory() -> URL {
fileManager.temporaryDirectory
}
func listContents(
directory: URL,
properties: [URLResourceKey]
) throws -> [URL] {
try fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: properties
)
}
func startDownload(fileURL: URL) throws {
assertionFailure("Shouldn't have called \(#function) in \(Self.self)")
}
func ubiquitousInfo(url: URL) -> UbiquitousInfo? {
assertionFailure("Shouldn't have called \(#function) in \(Self.self)")
return nil
}
func directoryChanges(directory: URL) -> AsyncStream {
AsyncStream { continuation in
continuation.yield()
let fd = open(directory.path, O_EVTONLY)
guard fd >= 0 else {
continuation.finish()
return
}
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .rename, .delete, .extend],
queue: .global()
)
source.setEventHandler {
continuation.yield()
}
source.setCancelHandler {
close(fd)
}
continuation.onTermination = { _ in
source.cancel()
}
source.resume()
}
}
func readFile(fileURL: URL) throws -> Data {
let coordinator = NSFileCoordinator()
var error: NSError?
var result: Result?
coordinator.coordinate(
readingItemAt: fileURL,
options: [],
error: &error
) { url in
result = Result(catching: {
try Data(contentsOf: url)
})
}
if let error {
throw error
}
guard let result else {
throw Failure.couldNotReadFileContents
}
return try result.get()
}
func writeFile(fileURL: URL, data: Data) throws {
let coordinator = NSFileCoordinator()
var error: NSError?
var result: Result?
coordinator.coordinate(
writingItemAt: fileURL,
options: .forReplacing,
error: &error
) { url in
result = Result(catching: {
try data.write(to: url, options: .atomic)
})
}
if let error {
throw error
}
guard let result else {
throw Failure.couldNotWriteFileContents
}
try result.get()
}
func fileExists(fileURL: URL) -> Bool {
var isDirectory = ObjCBool(false)
return fileManager.fileExists(atPath: fileURL.path, isDirectory: &isDirectory)
}
func removeFile(fileURL: URL) throws {
try fileManager.removeItem(at: fileURL)
}
func moveFile(fileURL: URL, newFileURL: URL) throws {
try fileManager.moveItem(at: fileURL, to: newFileURL)
}
func duplicateFile(fileURL: URL) throws -> URL {
var duplicateCount = 0
let fileName = fileURL.deletingPathExtension().lastPathComponent
while true {
let destinationFileName =
if duplicateCount == 0 {
L10n.FileSystem.Duplicate.FileName.plain(fileName)
} else {
L10n.FileSystem.Duplicate.FileName.multi(fileName, duplicateCount)
}
duplicateCount += 1
let destinationFileURL =
fileURL
.deletingPathExtension()
.deletingLastPathComponent()
.appendingPathComponent(destinationFileName)
.appendingPathExtension(fileURL.pathExtension)
do {
try fileManager.copyItem(at: fileURL, to: destinationFileURL)
return destinationFileURL
} catch CocoaError.fileWriteFileExists {
continue
}
}
}
}
================================================
FILE: Sources/FileService/UbiquitousFileService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
struct UbiquitousFileService: FileServiceProtocol {
nonisolated(unsafe) private let fileManager: FileManager
private let localFileService: FileServiceProtocol
init(
fileManager: FileManager,
localFileService: FileServiceProtocol
) {
self.fileManager = fileManager
self.localFileService = localFileService
}
func isEnabled() -> Bool {
fileManager.ubiquityIdentityToken != nil
}
func initializeDocumentsDirectory() async throws {
guard
isEnabled(),
let directory = try await documentsDirectory()
else {
return
}
let placeholder = directory.appendingPathComponent(".jottre", isDirectory: false)
guard !fileExists(fileURL: placeholder) else {
return
}
try writeFile(fileURL: placeholder, data: Data())
}
func documentsDirectory() async throws -> URL? {
guard
let directory = fileManager.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents")
else {
return nil
}
var isDirectory = ObjCBool(true)
if !fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
return directory
}
func temporaryDirectory() -> URL {
localFileService.temporaryDirectory()
}
func listContents(
directory: URL,
properties: [URLResourceKey]
) throws -> [URL] {
try localFileService.listContents(
directory: directory,
properties: properties
)
}
func startDownload(fileURL: URL) throws {
try fileManager.startDownloadingUbiquitousItem(at: fileURL)
}
func ubiquitousInfo(url: URL) -> UbiquitousInfo? {
guard fileManager.isUbiquitousItem(at: url) else {
return nil
}
let resourceValues = try? url.resourceValues(
forKeys: [.ubiquitousItemDownloadingStatusKey, .ubiquitousItemIsDownloadingKey]
)
let downloadStatus: UbiquitousInfo.DownloadStatus? =
switch resourceValues?.ubiquitousItemDownloadingStatus {
case .current:
UbiquitousInfo.DownloadStatus.current
case .downloaded:
UbiquitousInfo.DownloadStatus.downloaded
case .notDownloaded:
UbiquitousInfo.DownloadStatus.notDownloaded
default:
nil
}
return UbiquitousInfo(
downloadStatus: downloadStatus,
isDownloading: resourceValues?.ubiquitousItemIsDownloading ?? false
)
}
func directoryChanges(directory: URL) -> AsyncStream {
assert(
fileManager.isUbiquitousItem(at: directory),
"Cannot listen to directory changes of a non ubiquitous directory."
)
return AsyncStream { continuation in
continuation.yield()
let observer = UbiquitousDirectoryObserver {
continuation.yield()
}
continuation.onTermination = { _ in
observer.stop()
}
}
}
func readFile(fileURL: URL) throws -> Data {
try localFileService.readFile(fileURL: fileURL)
}
func writeFile(
fileURL: URL,
data: Data
) throws {
try localFileService.writeFile(
fileURL: fileURL,
data: data
)
}
func fileExists(fileURL: URL) -> Bool {
localFileService.fileExists(fileURL: fileURL)
}
func removeFile(fileURL: URL) throws {
try localFileService.removeFile(fileURL: fileURL)
}
func moveFile(fileURL: URL, newFileURL: URL) throws {
try localFileService.moveFile(
fileURL: fileURL,
newFileURL: newFileURL
)
}
func duplicateFile(fileURL: URL) throws -> URL {
try localFileService.duplicateFile(fileURL: fileURL)
}
}
private final class UbiquitousDirectoryObserver: @unchecked Sendable {
private let queue: OperationQueue = {
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
return queue
}()
private let query = NSMetadataQuery()
private var observers = [Any]()
init(onUpdate: @Sendable @escaping () -> Void) {
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
query.predicate = NSPredicate(format: "%K LIKE '*.\(JotFile.Info.fileExtension)'", NSMetadataItemFSNameKey)
query.operationQueue = queue
for name: NSNotification.Name in [.NSMetadataQueryDidFinishGathering, .NSMetadataQueryDidUpdate] {
observers.append(
NotificationCenter.default.addObserver(forName: name, object: query, queue: queue) { _ in
onUpdate()
}
)
}
queue.addOperation { [self] in
query.start()
}
}
func stop() {
queue.addOperation { [self] in
query.stop()
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}
}
}
================================================
FILE: Sources/FileService/UbiquitousInfo.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct UbiquitousInfo: Sendable, Hashable {
enum DownloadStatus: Sendable, Hashable {
case downloaded
case current
case notDownloaded
}
let downloadStatus: DownloadStatus?
let isDownloading: Bool
}
================================================
FILE: Sources/InfoAlertCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class InfoAlertCoordinator: Coordinator {
var onEnd: (() -> Void)?
private let navigation: Navigation
private let title: String
private let message: String?
init(
navigation: Navigation,
title: String,
message: String?
) {
self.navigation = navigation
self.title = title
self.message = message
}
func start() {
let alertController = UIAlertController(
title: title,
message: message,
preferredStyle: .alert
)
let okAction = UIAlertAction(
title: L10n.Action.ok,
style: .cancel,
handler: { [weak self] _ in
self?.onEnd?()
}
)
alertController.addAction(okAction)
navigation.present(alertController, animated: true)
}
}
================================================
FILE: Sources/Jot/Jot.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@preconcurrency import PencilKit
struct Jot: Codable, Sendable {
static func makeEmpty() -> Jot {
Jot(
version: 3,
drawing: PKDrawing().dataRepresentation(),
width: 1200
)
}
var version = Int(3)
let drawing: Data
let width: CGFloat
// NOTE: Kept for backwards compatibility.
var lastModified: Double? = Double.zero
}
================================================
FILE: Sources/Jot/JotFile.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
struct JotFile: Sendable {
struct Info: Sendable, Hashable {
static let fileExtension = "jot"
let url: URL
let name: String
let modificationDate: Date?
let ubiquitousInfo: UbiquitousInfo?
init(
url: URL,
name: String,
modificationDate: Date?,
ubiquitousInfo: UbiquitousInfo?
) {
self.url = url
self.name = name
self.modificationDate = modificationDate
self.ubiquitousInfo = ubiquitousInfo
}
init?(
url: URL,
modificationDate: Date?,
ubiquitousInfo: UbiquitousInfo?
) {
guard url.pathExtension == Info.fileExtension else {
return nil
}
self.init(
url: url,
name: url.deletingPathExtension().lastPathComponent,
modificationDate: modificationDate,
ubiquitousInfo: ubiquitousInfo
)
}
}
let info: Info
let jot: Jot
}
================================================
FILE: Sources/Jot/JotFileService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
protocol JotFileServiceProtocol: Sendable {
func documentsDirectoryContents() -> AsyncThrowingStream<[JotFile.Info], Error>
func readJotFile(jotFileInfo: JotFile.Info) throws -> JotFile
func write(jotFile: JotFile) throws
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info
func rename(jotFileInfo: JotFile.Info, newName: String) throws -> JotFile.Info
func remove(jotFileInfo: JotFile.Info) throws
func move(
jotFileInfo: JotFile.Info,
shouldBecomeUbiquitous: Bool
) async throws
}
struct JotFileService: JotFileServiceProtocol {
private enum Constants {
static func fileProperties(isUbiquitous: Bool) -> [URLResourceKey] {
var fileProperties: [URLResourceKey] = [
.contentModificationDateKey,
.isWritableKey,
.isReadableKey,
.isRegularFileKey,
]
if isUbiquitous {
fileProperties.append(.ubiquitousItemDownloadingStatusKey)
fileProperties.append(.ubiquitousItemIsDownloadingKey)
}
return fileProperties
}
}
enum Failure: Error {
case couldNotResolveDocumentsDirectory
}
private let propertyListDecoder = PropertyListDecoder()
private let propertyListEncoder = PropertyListEncoder()
private let localFileService: FileServiceProtocol
private let ubiquitousFileService: FileServiceProtocol
init(
localFileService: FileServiceProtocol,
ubiquitousFileService: FileServiceProtocol
) {
self.localFileService = localFileService
self.ubiquitousFileService = ubiquitousFileService
}
func documentsDirectoryContents() -> AsyncThrowingStream<[JotFile.Info], Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let ubiquitousDocumentsDirectory = try await ubiquitousFileService.documentsDirectory()
let localDocumentsDirectory = try await localFileService.documentsDirectory()
let documentsDirectories: [(isUbiquitous: Bool, directory: URL)] = [
(isUbiquitous: true, directory: ubiquitousDocumentsDirectory),
(isUbiquitous: false, directory: localDocumentsDirectory),
]
.compactMap { isUbiquitous, directory in
guard let directory else {
return nil
}
return (isUbiquitous: isUbiquitous, directory: directory)
}
try await withThrowingTaskGroup(of: Void.self) { group in
for (isUbiquitous, documentsDirectory) in documentsDirectories {
let fileService =
if isUbiquitous {
ubiquitousFileService
} else {
localFileService
}
group.addTask {
for try await _ in fileService.directoryChanges(directory: documentsDirectory) {
try continuation.yield(
documentsDirectories
.flatMap { (isUbiquitous: Bool, directory: URL) in
try listContents(
directory: directory,
isUbiquitous: isUbiquitous
)
}
)
}
}
}
try await group.waitForAll()
}
} catch {
continuation.finish(throwing: error)
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
private func listContents(
directory: URL,
isUbiquitous: Bool
) throws -> [JotFile.Info] {
let fileService =
if isUbiquitous {
ubiquitousFileService
} else {
localFileService
}
let contents = try fileService.listContents(
directory: directory,
properties: Constants.fileProperties(isUbiquitous: isUbiquitous)
)
return
try contents
.map { content in
try (
content: content,
properties:
content
.resourceValues(forKeys: Set(Constants.fileProperties(isUbiquitous: isUbiquitous)))
)
}
.filter { (fileURL: URL, properties: URLResourceValues) in
properties.isRegularFile == true
&& properties.isReadable == true
&& properties.isWritable == true
&& fileURL.pathExtension == JotFile.Info.fileExtension
}
.map { (fileURL: URL, properties: URLResourceValues) in
lazy var downloadStatus: UbiquitousInfo.DownloadStatus? =
switch properties.ubiquitousItemDownloadingStatus {
case .current:
UbiquitousInfo.DownloadStatus.current
case .downloaded:
UbiquitousInfo.DownloadStatus.downloaded
case .notDownloaded:
UbiquitousInfo.DownloadStatus.notDownloaded
default:
nil
}
let ubiqitousInfo =
isUbiquitous
? UbiquitousInfo(
downloadStatus: downloadStatus,
isDownloading: properties.ubiquitousItemIsDownloading ?? false
)
: nil
return JotFile.Info(
url: fileURL,
name: fileURL.deletingPathExtension().lastPathComponent,
modificationDate: properties.contentModificationDate,
ubiquitousInfo: ubiqitousInfo
)
}
}
func readJotFile(jotFileInfo: JotFile.Info) throws -> JotFile {
let fileService =
if jotFileInfo.ubiquitousInfo != nil {
ubiquitousFileService
} else {
localFileService
}
let data = try fileService.readFile(fileURL: jotFileInfo.url)
let jot = try propertyListDecoder.decode(Jot.self, from: data)
return JotFile(
info: jotFileInfo,
jot: jot
)
}
func write(jotFile: JotFile) throws {
let fileService =
if jotFile.info.ubiquitousInfo != nil {
ubiquitousFileService
} else {
localFileService
}
let data = try propertyListEncoder.encode(jotFile.jot)
try fileService.writeFile(
fileURL: jotFile.info.url,
data: data
)
}
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info {
let fileService =
if jotFileInfo.ubiquitousInfo != nil {
ubiquitousFileService
} else {
localFileService
}
let duplicatedFileURL = try fileService.duplicateFile(fileURL: jotFileInfo.url)
return JotFile.Info(
url: duplicatedFileURL,
name: duplicatedFileURL.deletingPathExtension().lastPathComponent,
modificationDate: jotFileInfo.modificationDate,
ubiquitousInfo: jotFileInfo.ubiquitousInfo
)
}
func rename(
jotFileInfo: JotFile.Info,
newName: String
) throws -> JotFile.Info {
let newFileURL = jotFileInfo.url
.deletingPathExtension()
.deletingLastPathComponent()
.appendingPathComponent(newName)
.appendingPathExtension(jotFileInfo.url.pathExtension)
let fileService =
if jotFileInfo.ubiquitousInfo != nil {
ubiquitousFileService
} else {
localFileService
}
try fileService.moveFile(
fileURL: jotFileInfo.url,
newFileURL: newFileURL
)
return JotFile.Info(
url: newFileURL,
name: newName,
modificationDate: jotFileInfo.modificationDate,
ubiquitousInfo: jotFileInfo.ubiquitousInfo
)
}
func remove(jotFileInfo: JotFile.Info) throws {
let fileService =
if jotFileInfo.ubiquitousInfo != nil {
ubiquitousFileService
} else {
localFileService
}
try fileService.removeFile(fileURL: jotFileInfo.url)
}
func move(
jotFileInfo: JotFile.Info,
shouldBecomeUbiquitous: Bool
) async throws {
let fileService =
if shouldBecomeUbiquitous {
ubiquitousFileService
} else {
localFileService
}
guard let documentsDirectory = try await fileService.documentsDirectory() else {
throw Failure.couldNotResolveDocumentsDirectory
}
try fileService.moveFile(
fileURL: jotFileInfo.url,
newFileURL: documentsDirectory.appendingPathComponent(jotFileInfo.url.lastPathComponent, isDirectory: false)
)
}
}
================================================
FILE: Sources/Jot/JotFileVersion.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct JotFileVersion: Sendable, Hashable {
let localizedNameOfSavingComputer: String?
let info: JotFile.Info
}
================================================
FILE: Sources/JotConflictPage/JotConflictCell/JotConflictBusinessModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
struct JotConflictBusinessModel: Sendable, Hashable {
let name: String
let lastEditedDateString: String
let jotFileInfo: JotFile.Info
private let jotFileVersion: JotFileVersion
init(
name: String,
jotFileInfo: JotFile.Info,
jotFileVersion: JotFileVersion
) {
self.name = name
lastEditedDateString = jotFileVersion.localizedNameOfSavingComputer ?? "n/a"
self.jotFileInfo = jotFileInfo
self.jotFileVersion = jotFileVersion
}
func toJotFileVersion() -> JotFileVersion {
jotFileVersion
}
}
================================================
FILE: Sources/JotConflictPage/JotConflictCell/JotConflictCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class JotConflictCell: UICollectionViewCell, PageCell {
static let reuseIdentifier = "JotConflictCell"
private let previewImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
return imageView
}()
private let separatorLine: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = UIColor.separator
return view
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .body, weight: .semibold)
return label
}()
private let infoLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .caption1, weight: .semibold)
label.textColor = .secondaryLabel
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
assertionFailure("\(#function) has not been implemented")
return nil
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasRenderingChange(comparedTo: previousTraitCollection) {
loadPreviewImage()
}
}
private func setUpViews() {
contentView.backgroundColor = .secondarySystemGroupedBackground
contentView.layer.cornerRadius = DesignTokens.CornerRadius.cell
contentView.clipsToBounds = true
contentView.layoutMargins = UIEdgeInsets(
top: DesignTokens.Spacing.xs,
left: DesignTokens.Spacing.xs,
bottom: DesignTokens.Spacing.sm,
right: DesignTokens.Spacing.xs
)
contentView.addSubview(previewImageView)
contentView.addSubview(separatorLine)
contentView.addSubview(nameLabel)
contentView.addSubview(infoLabel)
NSLayoutConstraint.activate(
[
previewImageView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
previewImageView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
previewImageView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
previewImageView.bottomAnchor.constraint(equalTo: separatorLine.topAnchor),
separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: DesignTokens.Length.separator),
nameLabel.topAnchor.constraint(equalTo: separatorLine.bottomAnchor, constant: DesignTokens.Spacing.sm),
nameLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
nameLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
infoLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor),
infoLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
infoLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
infoLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
]
)
}
private var viewModel: JotConflictCellViewModel?
private var previewImageTask: Task?
override func prepareForReuse() {
super.prepareForReuse()
previewImageView.image = nil
}
func configure(
viewModel: JotConflictCellViewModel
) {
self.viewModel = viewModel
nameLabel.text = viewModel.name
infoLabel.text = viewModel.infoText
loadPreviewImage()
}
private func loadPreviewImage() {
guard let viewModel else {
return
}
previewImageTask?.cancel()
previewImageTask = Task { [weak self] in
guard let self else {
return
}
previewImageView.image = await viewModel.getPreviewImage(
userInterfaceStyle: traitCollection.userInterfaceStyle,
displayScale: traitCollection.displayScale
)
}
}
}
================================================
FILE: Sources/JotConflictPage/JotConflictCell/JotConflictCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class JotConflictCellViewModel: PageCellViewModel {
let name: String
let infoText: String
private let jotConflict: JotConflictBusinessModel
private let repository: JotConflictRepositoryProtocol
init(
jotConflict: JotConflictBusinessModel,
repository: JotConflictRepositoryProtocol
) {
name = jotConflict.name
infoText = jotConflict.lastEditedDateString
self.jotConflict = jotConflict
self.repository = repository
}
func handle(action: PageCellAction) {
/* no-op */
}
func getPreviewImage(
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
await repository.getPreviewImage(
jotFileInfo: jotConflict.jotFileInfo,
jotFileVersion: jotConflict.toJotFileVersion(),
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
}
}
================================================
FILE: Sources/JotConflictPage/JotConflictCell/PageCellItem+jotConflict.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func jotConflict(
jotConflict: JotConflictBusinessModel,
sizing: PageCellSizingStrategy,
repository: JotConflictRepositoryProtocol
) -> PageCellItem {
PageCellItem(
id: jotConflict,
cellType: JotConflictCell.self,
sizing: sizing,
viewModel: JotConflictCellViewModel(
jotConflict: jotConflict,
repository: repository
)
)
}
}
================================================
FILE: Sources/JotConflictPage/JotConflictCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol JotConflictCoordinatorProtocol: Coordinator {
func showInfoAlert(title: String, message: String)
func dismiss(completion: @Sendable @escaping () -> Void)
}
final class JotConflictCoordinator: Coordinator, JotConflictCoordinatorProtocol {
private var retainedInfoAlertCoordinator: Coordinator?
var onEnd: (() -> Void)?
private let jotFileInfo: JotFile.Info
private let jotFileVersions: [JotFileVersion]
private let repository: JotConflictRepositoryProtocol
private let navigation: Navigation
private let jotConflictViewControllerFactory: JotConflictViewControllerFactoryProtocol
private let onResult: @Sendable (_ result: JotConflictResult) -> Void
init(
jotFileInfo: JotFile.Info,
jotFileVersions: [JotFileVersion],
repository: JotConflictRepositoryProtocol,
navigation: Navigation,
jotConflictViewControllerFactory: JotConflictViewControllerFactoryProtocol,
onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void
) {
self.jotFileInfo = jotFileInfo
self.jotFileVersions = jotFileVersions
self.repository = repository
self.navigation = navigation
self.jotConflictViewControllerFactory = jotConflictViewControllerFactory
self.onResult = onResult
}
func start() {
let viewController = jotConflictViewControllerFactory.make(
viewModel: JotConflictViewModel(
jotFileInfo: jotFileInfo,
jotFileVersions: jotFileVersions,
repository: repository,
coordinator: self,
onResult: onResult
)
)
viewController.isModalInPresentation = true
let navigationController = UINavigationController(
rootViewController: viewController
)
navigationController.navigationBar.prefersLargeTitles = false
navigation.present(navigationController, animated: true)
}
func showInfoAlert(
title: String,
message: String
) {
let infoAlertCoordinator = InfoAlertCoordinator(
navigation: navigation,
title: title,
message: message
)
infoAlertCoordinator.onEnd = { [weak self] in
self?.retainedInfoAlertCoordinator = nil
}
retainedInfoAlertCoordinator = infoAlertCoordinator
infoAlertCoordinator.start()
}
func dismiss(completion: @Sendable @escaping () -> Void) {
navigation.dismiss(animated: true) { [weak self] in
completion()
Task { @MainActor in
self?.onEnd?()
}
}
}
}
================================================
FILE: Sources/JotConflictPage/JotConflictCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@MainActor
protocol JotConflictCoordinatorFactoryProtocol: Sendable {
func make(
jotFileInfo: JotFile.Info,
jotFileVersions: [JotFileVersion],
navigation: Navigation,
onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void
) -> Coordinator
}
struct JotConflictCoordinatorFactory: JotConflictCoordinatorFactoryProtocol {
let jotConflictViewControllerFactory: JotConflictViewControllerFactoryProtocol
let repository: JotConflictRepositoryProtocol
func make(
jotFileInfo: JotFile.Info,
jotFileVersions: [JotFileVersion],
navigation: Navigation,
onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void
) -> Coordinator {
JotConflictCoordinator(
jotFileInfo: jotFileInfo,
jotFileVersions: jotFileVersions,
repository: repository,
navigation: navigation,
jotConflictViewControllerFactory: jotConflictViewControllerFactory,
onResult: onResult
)
}
}
================================================
FILE: Sources/JotConflictPage/JotConflictRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol JotConflictRepositoryProtocol: Sendable {
func resolveVersionConflicts(
jotFileInfo: JotFile.Info,
resolvedVersions: [JotFileVersion]
) throws
func getPreviewImage(
jotFileInfo: JotFile.Info,
jotFileVersion: JotFileVersion,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage?
}
struct JotConflictRepository: JotConflictRepositoryProtocol {
private let jotFileConflictService: JotFileConflictServiceProtocol
private let jotFilePreviewImageService: JotFilePreviewImageServiceProtocol
private let logger: LoggerProtocol
init(
jotFileConflictService: JotFileConflictServiceProtocol,
jotFilePreviewImageService: JotFilePreviewImageServiceProtocol,
logger: LoggerProtocol
) {
self.jotFileConflictService = jotFileConflictService
self.jotFilePreviewImageService = jotFilePreviewImageService
self.logger = logger
}
func resolveVersionConflicts(
jotFileInfo: JotFile.Info,
resolvedVersions: [JotFileVersion]
) throws {
try jotFileConflictService.resolveVersionConflicts(
jotFileInfo: jotFileInfo,
resolvedVersions: resolvedVersions
)
}
func getPreviewImage(
jotFileInfo: JotFile.Info,
jotFileVersion: JotFileVersion,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
do {
let readableVersionFileInfo: JotFile.Info
let tmpURL: URL?
if let tmpInfo = try jotFileConflictService.copyVersionToTemporary(
jotFileInfo: jotFileInfo,
jotFileVersion: jotFileVersion
) {
readableVersionFileInfo = tmpInfo
tmpURL = tmpInfo.url
} else {
readableVersionFileInfo = jotFileInfo
tmpURL = nil
}
defer {
if let tmpURL {
try? FileManager.default.removeItem(at: tmpURL)
}
}
let imageData = try await jotFilePreviewImageService.getPreviewImageData(
jotFileInfo: readableVersionFileInfo,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
return UIImage(data: imageData)
} catch {
logger.error("Failed to load conflict preview image: \(error)")
return nil
}
}
}
================================================
FILE: Sources/JotConflictPage/JotConflictResult.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
enum JotConflictResult: Sendable {
case keepAll
case keep(JotFile.Info)
}
================================================
FILE: Sources/JotConflictPage/JotConflictViewControllerFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol JotConflictViewControllerFactoryProtocol: Sendable {
func make(
viewModel: JotConflictViewModel
) -> UIViewController
}
struct JotConflictViewControllerFactory: JotConflictViewControllerFactoryProtocol {
let textBarButtonItemFactory: TextBarButtonItemFactory
let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
func make(
viewModel: JotConflictViewModel
) -> UIViewController {
PageViewController(
viewModel: viewModel,
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
)
}
}
================================================
FILE: Sources/JotConflictPage/JotConflictViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@MainActor
final class JotConflictViewModel: PageViewModel, Sendable {
private enum Constants {
static func character(offset: Int) -> String {
UnicodeScalar(65 + offset)?.description ?? String()
}
}
private(set) lazy var items: AsyncStream<[PageCellItem]> = AsyncStream<[PageCellItem]>(
[PageCellItem].self,
bufferingPolicy: .bufferingNewest(1)
) { continuation in
var pageCellItems = [
PageCellItem.pageHeader(
headline: L10n.JotConflict.title,
subheadline: jotFileVersions.first.map { L10n.JotConflict.subtitle($0.info.name) } ?? String()
)
]
pageCellItems.append(
contentsOf:
jotFileVersions
.enumerated()
.map { offset, jotFileVersion in
PageCellItem.jotConflict(
jotConflict: JotConflictBusinessModel(
name: L10n.JotConflict.versionName(Constants.character(offset: offset)),
jotFileInfo: jotFileInfo,
jotFileVersion: jotFileVersion
),
sizing: .equalSplit(
perRow: jotFileVersions.count,
itemHeight: 200
),
repository: repository
)
}
)
continuation.yield(pageCellItems)
continuation.finish()
}
private(set) lazy var actions =
jotFileVersions
.enumerated()
.map { (offset, jotFileVersion) in
PageCallToActionView.ActionConfiguration(
style: .primary,
title: L10n.JotConflict.Action.keepVersion(Constants.character(offset: offset)),
icon: nil
) { [weak self] in
self?.didTapKeepVersion(jotFileVersion: jotFileVersion)
}
} + [
PageCallToActionView.ActionConfiguration(
style: .secondary,
title: L10n.JotConflict.Action.keepAll,
icon: nil
) { [weak self] in
self?.didTapKeepAll()
}
]
private let jotFileInfo: JotFile.Info
private let jotFileVersions: [JotFileVersion]
private let repository: JotConflictRepositoryProtocol
private weak var coordinator: JotConflictCoordinatorProtocol?
private let onResult: @Sendable (_ result: JotConflictResult) -> Void
init(
jotFileInfo: JotFile.Info,
jotFileVersions: [JotFileVersion],
repository: JotConflictRepositoryProtocol,
coordinator: JotConflictCoordinatorProtocol,
onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void
) {
assert(jotFileVersions.count >= 1, "Resolving a version conflict between less than two files is not logical.")
self.jotFileInfo = jotFileInfo
self.jotFileVersions =
[
JotFileVersion(
localizedNameOfSavingComputer: L10n.JotConflict.deviceLabel,
info: jotFileInfo
)
] + jotFileVersions
self.repository = repository
self.coordinator = coordinator
self.onResult = onResult
}
private func didTapKeepVersion(jotFileVersion: JotFileVersion) {
do {
try repository.resolveVersionConflicts(
jotFileInfo: jotFileInfo,
resolvedVersions: [jotFileVersion]
)
coordinator?.dismiss(completion: { [weak self] in
guard let self else {
return
}
onResult(.keep(jotFileInfo))
})
} catch {
coordinator?.showInfoAlert(
title: L10n.JotConflict.Error.generic,
message: error.localizedDescription
)
}
}
private func didTapKeepAll() {
do {
try repository.resolveVersionConflicts(
jotFileInfo: jotFileInfo,
resolvedVersions: jotFileVersions
)
coordinator?.dismiss(completion: { [weak self] in
self?.onResult(.keepAll)
})
} catch {
coordinator?.showInfoAlert(
title: L10n.JotConflict.Error.generic,
message: error.localizedDescription
)
}
}
}
================================================
FILE: Sources/JotConflictPage/JotFileConflictService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
protocol JotFileConflictServiceProtocol: Sendable {
func getConfictingVersions(jotFileInfo: JotFile.Info) -> [JotFileVersion]?
func resolveVersionConflicts(
jotFileInfo: JotFile.Info,
resolvedVersions: [JotFileVersion]
) throws
func copyVersionToTemporary(
jotFileInfo: JotFile.Info,
jotFileVersion: JotFileVersion
) throws -> JotFile.Info?
}
struct JotFileConflictService: JotFileConflictServiceProtocol {
private let fileConflictService: FileConflictServiceProtocol
init(fileConflictService: FileConflictServiceProtocol) {
self.fileConflictService = fileConflictService
}
func getConfictingVersions(jotFileInfo: JotFile.Info) -> [JotFileVersion]? {
guard let fileVersions = fileConflictService.getConflictingVersions(fileURL: jotFileInfo.url),
!fileVersions.isEmpty
else {
return nil
}
return
fileVersions
.map { fileVersion in
JotFileVersion(
localizedNameOfSavingComputer: fileVersion.localizedNameOfSavingComputer,
info: JotFile.Info(
url: fileVersion.url,
name: fileVersion.localizedName ?? fileVersion.url.deletingPathExtension().lastPathComponent,
modificationDate: fileVersion.modificationDate,
ubiquitousInfo: UbiquitousInfo(downloadStatus: nil, isDownloading: false)
)
)
}
}
func resolveVersionConflicts(
jotFileInfo: JotFile.Info,
resolvedVersions: [JotFileVersion]
) throws {
try fileConflictService.resolveVersionConflicts(
fileURL: jotFileInfo.url,
resolvedVersions: resolvedVersions.map(\.info.url)
)
}
func copyVersionToTemporary(
jotFileInfo: JotFile.Info,
jotFileVersion: JotFileVersion
) throws -> JotFile.Info? {
guard
let tmpURL = try fileConflictService.copyVersionToTemporary(
fileURL: jotFileInfo.url,
versionURL: jotFileVersion.info.url
)
else {
return nil
}
return JotFile.Info(
url: tmpURL,
name: jotFileVersion.info.name,
modificationDate: jotFileVersion.info.modificationDate,
ubiquitousInfo: jotFileVersion.info.ubiquitousInfo
)
}
}
================================================
FILE: Sources/JotFilePreview/CachedJotFilePreviewImageService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import CryptoKit
import UIKit
actor CachedJotFilePreviewImageService: JotFilePreviewImageServiceProtocol {
private enum Constants {
static let diskCacheDirectoryName = "JotFilePreviewCache"
static let memoryCacheSizeLimit = 20 * 1024 * 1024 // 20 MB
}
private struct CacheKey: CustomStringConvertible {
let jotFilePath: String
let modificationDate: Date?
let userInterfaceStyle: UIUserInterfaceStyle
let displayScale: CGFloat
var description: String {
[
jotFilePath.description,
modificationDate.map(\.timeIntervalSince1970.description),
userInterfaceStyle.rawValue.description,
displayScale.description,
]
.compactMap { $0 }
.joined(separator: "|")
}
}
private let localFileService: FileServiceProtocol
private let jotFilePreviewImageService: JotFilePreviewImageServiceProtocol
private let memoryCache: NSCache
private let temporaryDirectory: URL
init(
localFileService: FileServiceProtocol,
jotFilePreviewImageService: JotFilePreviewImageServiceProtocol
) {
self.localFileService = localFileService
self.jotFilePreviewImageService = jotFilePreviewImageService
let cache = NSCache()
cache.totalCostLimit = Constants.memoryCacheSizeLimit
self.memoryCache = cache
temporaryDirectory =
localFileService
.temporaryDirectory()
.appendingPathComponent(Constants.diskCacheDirectoryName, isDirectory: true)
try? FileManager.default.createDirectory(
at: temporaryDirectory,
withIntermediateDirectories: true
)
}
func getPreviewImageData(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async throws -> Data {
let memoryCacheKey = makeMemoryCacheKey(
jotFileInfo: jotFileInfo,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
if let cached = memoryCache.object(forKey: memoryCacheKey) {
return cached as Data
}
let diskCacheFileURL = makeDiskCacheFileURL(
jotFileInfo: jotFileInfo,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
if let diskCacheFileURL,
let cachedPreviewImageData = try? localFileService.readFile(fileURL: diskCacheFileURL)
{
memoryCache.setObject(
cachedPreviewImageData as NSData,
forKey: memoryCacheKey,
cost: cachedPreviewImageData.count
)
return cachedPreviewImageData
}
let previewImageData = try await jotFilePreviewImageService.getPreviewImageData(
jotFileInfo: jotFileInfo,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
memoryCache.setObject(
previewImageData as NSData,
forKey: memoryCacheKey,
cost: previewImageData.count
)
if let diskCacheFileURL {
try? localFileService.writeFile(fileURL: diskCacheFileURL, data: previewImageData)
}
return previewImageData
}
private func makeMemoryCacheKey(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) -> NSString {
makeCacheKey(
jotFilePath: jotFileInfo.url.path,
modificationDate: jotFileInfo.modificationDate,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
) as NSString
}
private func makeDiskCacheFileURL(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) -> URL? {
guard let modificationDate = jotFileInfo.modificationDate else {
return nil
}
let cacheKey = makeCacheKey(
jotFilePath: jotFileInfo.url.path,
modificationDate: modificationDate,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
return temporaryDirectory.appendingPathComponent(cacheKey, isDirectory: false)
}
private func makeCacheKey(
jotFilePath: String,
modificationDate: Date?,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) -> String {
SHA256.hash(
data: Data(
CacheKey(
jotFilePath: jotFilePath,
modificationDate: modificationDate,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
).description.utf8
)
)
.map { String(format: "%02x", $0) }
.joined()
}
}
================================================
FILE: Sources/JotFilePreview/JotFilePreviewImageService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import PencilKit
import UIKit
struct JotFilePreviewImageService: JotFilePreviewImageServiceProtocol {
enum Constants {
static let size = CGSize(width: 160, height: 160)
}
enum Failure: Error {
case couldNotRenderImage
case fileNotDownloaded
}
private let jotFileService: JotFileServiceProtocol
init(jotFileService: JotFileServiceProtocol) {
self.jotFileService = jotFileService
}
func getPreviewImageData(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async throws -> Data {
guard jotFileInfo.ubiquitousInfo?.downloadStatus != .notDownloaded else {
throw Failure.fileNotDownloaded
}
let jotFile = try jotFileService.readJotFile(jotFileInfo: jotFileInfo)
let drawing = try PKDrawing(data: jotFile.jot.drawing)
let aspectRatio = Constants.size.width / Constants.size.height
let rect = CGRect(
x: .zero,
y: .zero,
width: jotFile.jot.width,
height: jotFile.jot.width / aspectRatio
)
let scale = displayScale * Constants.size.width / jotFile.jot.width
let traitCollection = UITraitCollection(userInterfaceStyle: userInterfaceStyle)
let image = await MainActor.run {
var image: UIImage?
traitCollection.performAsCurrent {
image = drawing.image(from: rect, scale: scale)
}
return image
}
guard let imageData = image?.pngData() else {
throw Failure.couldNotRenderImage
}
return imageData
}
}
================================================
FILE: Sources/JotFilePreview/JotFilePreviewImageServiceProtocol.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol JotFilePreviewImageServiceProtocol: Sendable {
func getPreviewImageData(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async throws -> Data
}
================================================
FILE: Sources/JotsPage/CreateJot/CreateJotCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
final class CreateJotCoordinator: Coordinator {
var onEnd: (() -> Void)?
private var retainedInfoAlertCoordinator: Coordinator?
private let navigation: Navigation
private let repository: CreateJotRepositoryProtocol
init(
navigation: Navigation,
repository: CreateJotRepositoryProtocol
) {
self.navigation = navigation
self.repository = repository
}
func start() {
let alertController = UIAlertController(
title: L10n.Jots.Create.title,
message: nil,
preferredStyle: .alert
)
alertController.addTextField { textField in
textField.placeholder = L10n.Jots.Create.namePlaceholder
textField.autocapitalizationType = .sentences
textField.returnKeyType = .done
}
let createAction = UIAlertAction(
title: L10n.Action.create,
style: .default
) { [weak self] _ in
guard
let self,
let name = alertController.textFields?.first?.text,
!name.isEmpty
else {
return
}
handleCreateJot(name: name)
}
alertController.addAction(createAction)
let cancelAction = UIAlertAction(
title: L10n.Action.cancel,
style: .cancel,
handler: { [weak self] _ in
self?.onEnd?()
}
)
alertController.addAction(cancelAction)
navigation.present(alertController, animated: true)
}
private func handleCreateJot(name: String) {
Task { [weak self] in
guard let self else {
return
}
do {
try await handleCreateJot(name: name)
} catch CreateJotRepository.Failure.fileExists {
showInfoAlert(
title: L10n.Jots.Create.Error.fileExists(name),
message: nil
)
} catch {
showInfoAlert(
title: L10n.Jots.Create.Error.generic,
message: error.localizedDescription
)
}
}
}
private func handleCreateJot(name: String) async throws {
let jotFileInfo = try await repository.createJot(name: name)
navigation.open(url: EditJotURL(jotFileInfo: jotFileInfo))
onEnd?()
}
private func showInfoAlert(
title: String,
message: String?
) {
let infoAlertCoordinator = InfoAlertCoordinator(
navigation: navigation,
title: title,
message: message
)
retainedInfoAlertCoordinator = infoAlertCoordinator
infoAlertCoordinator.onEnd = { [weak self] in
self?.retainedInfoAlertCoordinator = nil
self?.onEnd?()
}
infoAlertCoordinator.start()
}
}
================================================
FILE: Sources/JotsPage/CreateJot/CreateJotCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol CreateJotCoordinatorFactoryProtocol: Sendable {
func make(navigation: Navigation) -> Coordinator
}
struct CreateJotCoordinatorFactory: CreateJotCoordinatorFactoryProtocol {
let repository: CreateJotRepositoryProtocol
func make(navigation: Navigation) -> Coordinator {
CreateJotCoordinator(
navigation: navigation,
repository: repository
)
}
}
================================================
FILE: Sources/JotsPage/CreateJot/CreateJotRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol CreateJotRepositoryProtocol: Sendable {
func createJot(name: String) async throws -> JotFile.Info
}
struct CreateJotRepository: CreateJotRepositoryProtocol {
enum Failure: Error {
case couldNotCreateFile
case fileExists
}
private let localFileService: FileServiceProtocol
private let ubiquitousFileService: FileServiceProtocol
private let jotFileService: JotFileServiceProtocol
init(
localFileService: FileServiceProtocol,
ubiquitousFileService: FileServiceProtocol,
jotFileService: JotFileServiceProtocol
) {
self.localFileService = localFileService
self.ubiquitousFileService = ubiquitousFileService
self.jotFileService = jotFileService
}
func createJot(name: String) async throws -> JotFile.Info {
let fileService: FileServiceProtocol
let directory: URL
let isUbiquitous: Bool
if let ubiquitousDirectory = try await ubiquitousFileService.documentsDirectory() {
fileService = ubiquitousFileService
directory = ubiquitousDirectory
isUbiquitous = true
} else if let localDirectory = try await localFileService.documentsDirectory() {
fileService = localFileService
directory = localDirectory
isUbiquitous = false
} else {
throw Failure.couldNotCreateFile
}
let fileURL =
directory
.appendingPathComponent(name, isDirectory: false)
.appendingPathExtension(JotFile.Info.fileExtension)
guard !fileService.fileExists(fileURL: fileURL) else {
throw Failure.fileExists
}
let jotFile = JotFile(
info: JotFile.Info(
url: fileURL,
name: name,
modificationDate: nil,
ubiquitousInfo: isUbiquitous ? UbiquitousInfo(downloadStatus: .current, isDownloading: false) : nil
),
jot: .makeEmpty()
)
try jotFileService.write(jotFile: jotFile)
return jotFile.info
}
}
================================================
FILE: Sources/JotsPage/DeleteJot/DeleteJotCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class DeleteJotCoordinator: Coordinator {
var onEnd: (() -> Void)?
private var retainedInfoAlertCoordinator: Coordinator?
private let jotFileInfo: JotFile.Info
private let navigation: Navigation
private let repository: DeleteJotRepositoryProtocol
init(
jotFileInfo: JotFile.Info,
navigation: Navigation,
repository: DeleteJotRepositoryProtocol
) {
self.jotFileInfo = jotFileInfo
self.navigation = navigation
self.repository = repository
}
func start() {
let alertController = UIAlertController(
title: L10n.Jots.Delete.title,
message: L10n.Jots.Delete.message,
preferredStyle: .alert
)
alertController.addAction(
UIAlertAction(
title: L10n.Action.cancel,
style: .cancel
)
)
alertController.addAction(
UIAlertAction(
title: L10n.Action.delete,
style: .destructive
) { [weak self] _ in
guard let self else {
return
}
handleDeleteJot(jotFileInfo: jotFileInfo)
navigation.dismiss(animated: true) { [weak self] in
Task { @MainActor in
self?.onEnd?()
}
}
}
)
navigation.present(alertController, animated: true)
}
private func handleDeleteJot(jotFileInfo: JotFile.Info) {
do {
try repository.deleteJot(jotFileInfo: jotFileInfo)
} catch {
showInfoAlert(
title: L10n.Jots.Delete.Error.generic(jotFileInfo.name),
message: error.localizedDescription
)
}
}
private func showInfoAlert(
title: String,
message: String?
) {
let infoAlertCoordinator = InfoAlertCoordinator(
navigation: navigation,
title: title,
message: message
)
retainedInfoAlertCoordinator = infoAlertCoordinator
infoAlertCoordinator.onEnd = { [weak self] in
self?.retainedInfoAlertCoordinator = nil
}
infoAlertCoordinator.start()
}
}
================================================
FILE: Sources/JotsPage/DeleteJot/DeleteJotCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol DeleteJotCoordinatorFactoryProtocol: Sendable {
func make(
jotFileInfo: JotFile.Info,
navigation: Navigation
) -> Coordinator
}
struct DeleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol {
let repository: DeleteJotRepositoryProtocol
func make(
jotFileInfo: JotFile.Info,
navigation: Navigation
) -> Coordinator {
DeleteJotCoordinator(
jotFileInfo: jotFileInfo,
navigation: navigation,
repository: repository
)
}
}
================================================
FILE: Sources/JotsPage/DeleteJot/DeleteJotRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol DeleteJotRepositoryProtocol: Sendable {
func deleteJot(jotFileInfo: JotFile.Info) throws
}
struct DeleteJotRepository: DeleteJotRepositoryProtocol {
private let jotFileService: JotFileServiceProtocol
init(jotFileService: JotFileServiceProtocol) {
self.jotFileService = jotFileService
}
func deleteJot(jotFileInfo: JotFile.Info) throws {
try jotFileService.remove(jotFileInfo: jotFileInfo)
}
}
================================================
FILE: Sources/JotsPage/EmptyStateCell/EmptyStateCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class EmptyStateCell: UICollectionViewCell, PageCell {
static let reuseIdentifier = "EmptyStateCell"
private let titleLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .secondaryLabel
label.numberOfLines = 0
label.textAlignment = .center
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
assertionFailure("\(#function) has not been implemented")
return nil
}
private func setUpViews() {
contentView.backgroundColor = .secondarySystemBackground
contentView.addSubview(titleLabel)
NSLayoutConstraint.activate([
titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
titleLabel.widthAnchor.constraint(lessThanOrEqualTo: contentView.widthAnchor),
titleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 300),
])
}
func configure(
viewModel: EmptyStateCellViewModel
) {
titleLabel.text = viewModel.title
}
}
================================================
FILE: Sources/JotsPage/EmptyStateCell/EmptyStateViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
final class EmptyStateCellViewModel: PageCellViewModel {
let title: String
init(title: String) {
self.title = title
}
func handle(action: PageCellAction) {
/* no-op */
}
}
================================================
FILE: Sources/JotsPage/EmptyStateCell/PageCellItem+jotsEmptyState.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func jotsEmptyState(title: String) -> PageCellItem {
PageCellItem(
id: title,
cellType: EmptyStateCell.self,
sizing: .fullWidth(estimatedHeight: 100),
viewModel: EmptyStateCellViewModel(title: title)
)
}
}
================================================
FILE: Sources/JotsPage/JotCell/JotBusinessModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
struct JotBusinessModel: Sendable, Hashable {
let name: String
let isDownloaded: Bool
let isDownloading: Bool
private let jotFileInfo: JotFile.Info
init(jotFileInfo: JotFile.Info) {
name = jotFileInfo.name
isDownloaded = jotFileInfo.ubiquitousInfo?.downloadStatus != .notDownloaded
isDownloading = jotFileInfo.ubiquitousInfo?.isDownloading ?? false
self.jotFileInfo = jotFileInfo
}
func toJotFileInfo() -> JotFile.Info {
jotFileInfo
}
}
================================================
FILE: Sources/JotsPage/JotCell/JotCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class JotCell: UICollectionViewCell, PageCell {
private enum Constants {
enum CloudIconImage {
static let size = CGFloat(60)
}
}
static let reuseIdentifier = "JotCell"
private let previewLayoutGuide = UILayoutGuide()
private lazy var previewImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
return imageView
}()
private lazy var cloudIconImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.image = UIImage(systemName: "icloud.and.arrow.down.fill")
imageView.contentMode = .scaleAspectFit
imageView.tintColor = .secondaryLabel
return imageView
}()
private lazy var downloadActivityIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.translatesAutoresizingMaskIntoConstraints = false
indicator.hidesWhenStopped = true
return indicator
}()
private let separatorLine: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = UIColor.separator
return view
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
#if targetEnvironment(macCatalyst)
label.font = .preferredFont(forTextStyle: .body, weight: .semibold)
#else
label.font = .preferredFont(forTextStyle: .caption1, weight: .semibold)
#endif
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
assertionFailure("\(#function) has not been implemented")
return nil
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasRenderingChange(comparedTo: previousTraitCollection) {
loadPreviewImage()
}
}
private func setUpViews() {
contentView.backgroundColor = .secondarySystemGroupedBackground
contentView.layer.cornerRadius = DesignTokens.CornerRadius.cell
contentView.clipsToBounds = true
contentView.layoutMargins = UIEdgeInsets(
top: DesignTokens.Spacing.xs,
left: DesignTokens.Spacing.xs,
bottom: DesignTokens.Spacing.sm,
right: DesignTokens.Spacing.xs
)
contentView.addLayoutGuide(previewLayoutGuide)
contentView.addSubview(separatorLine)
contentView.addSubview(nameLabel)
NSLayoutConstraint.activate(
[
previewLayoutGuide.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
previewLayoutGuide.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
previewLayoutGuide.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
previewLayoutGuide.bottomAnchor.constraint(equalTo: separatorLine.topAnchor),
separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: DesignTokens.Length.separator),
nameLabel.topAnchor.constraint(equalTo: separatorLine.bottomAnchor, constant: DesignTokens.Spacing.sm),
nameLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
nameLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
nameLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
]
)
}
private var viewModel: JotCellViewModel?
private var previewImageTask: Task?
override func prepareForReuse() {
super.prepareForReuse()
viewModel = nil
previewImageTask?.cancel()
previewImageView.image = nil
previewImageView.removeFromSuperview()
cloudIconImageView.removeFromSuperview()
downloadActivityIndicator.stopAnimating()
downloadActivityIndicator.removeFromSuperview()
}
func configure(
viewModel: JotCellViewModel
) {
self.viewModel = viewModel
nameLabel.text = viewModel.name
loadPreviewImage()
}
private func loadPreviewImage() {
previewImageTask?.cancel()
previewImageView.image = nil
previewImageView.removeFromSuperview()
cloudIconImageView.removeFromSuperview()
downloadActivityIndicator.stopAnimating()
downloadActivityIndicator.removeFromSuperview()
guard let viewModel else {
return
}
switch viewModel.preview {
case .thumbnail:
contentView.addSubview(previewImageView)
NSLayoutConstraint.activate([
previewImageView.topAnchor.constraint(equalTo: previewLayoutGuide.topAnchor),
previewImageView.leadingAnchor.constraint(equalTo: previewLayoutGuide.leadingAnchor),
previewImageView.trailingAnchor.constraint(equalTo: previewLayoutGuide.trailingAnchor),
previewImageView.bottomAnchor.constraint(equalTo: previewLayoutGuide.bottomAnchor),
])
previewImageTask = Task { [weak self] in
guard let self else {
return
}
previewImageView.image = await viewModel.getPreviewImage(
userInterfaceStyle: traitCollection.userInterfaceStyle,
displayScale: traitCollection.displayScale
)
}
case .cloudImage:
contentView.addSubview(cloudIconImageView)
NSLayoutConstraint.activate([
cloudIconImageView.centerXAnchor.constraint(equalTo: previewLayoutGuide.centerXAnchor),
cloudIconImageView.centerYAnchor.constraint(equalTo: previewLayoutGuide.centerYAnchor),
cloudIconImageView.widthAnchor.constraint(equalToConstant: Constants.CloudIconImage.size),
cloudIconImageView.heightAnchor.constraint(equalToConstant: Constants.CloudIconImage.size),
])
case .loadingIndicator:
contentView.addSubview(cloudIconImageView)
NSLayoutConstraint.activate([
cloudIconImageView.centerXAnchor.constraint(equalTo: previewLayoutGuide.centerXAnchor),
cloudIconImageView.centerYAnchor.constraint(equalTo: previewLayoutGuide.centerYAnchor),
cloudIconImageView.widthAnchor.constraint(equalToConstant: Constants.CloudIconImage.size),
cloudIconImageView.heightAnchor.constraint(equalToConstant: Constants.CloudIconImage.size),
])
contentView.addSubview(downloadActivityIndicator)
NSLayoutConstraint.activate([
downloadActivityIndicator.centerXAnchor.constraint(equalTo: previewLayoutGuide.centerXAnchor),
downloadActivityIndicator.topAnchor.constraint(
equalTo: cloudIconImageView.bottomAnchor,
constant: DesignTokens.Spacing.sm
),
])
downloadActivityIndicator.startAnimating()
}
}
}
================================================
FILE: Sources/JotsPage/JotCell/JotCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class JotCellViewModel: PageCellViewModel {
enum Preview {
case thumbnail
case cloudImage
case loadingIndicator
}
let name: String
let preview: Preview
let jotMenuConfigurations: JotMenuConfigurations
let onAction: @Sendable () -> Void
private let jot: JotBusinessModel
private let repository: JotsRepositoryProtocol
init(
jot: JotBusinessModel,
jotMenuConfigurations: JotMenuConfigurations,
repository: JotsRepositoryProtocol,
onAction: @Sendable @escaping () -> Void
) {
self.name = jot.name
self.preview =
if jot.isDownloading {
.loadingIndicator
} else if jot.isDownloaded {
.thumbnail
} else {
.cloudImage
}
self.jotMenuConfigurations = jotMenuConfigurations
self.onAction = onAction
self.jot = jot
self.repository = repository
}
func handle(action: PageCellAction) {
switch action {
case .tap: onAction()
}
}
func getPreviewImage(
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
await repository.getPreviewImage(
jotFileInfo: jot.toJotFileInfo(),
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
}
func handleContextMenuConfiguration(
point: CGPoint,
sourceView: UIView
) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(
identifier: nil,
previewProvider: nil
) { [weak self, weak sourceView] _ in
guard let self else {
return nil
}
return UIMenu.make(
jotMenuConfigurations: self.jotMenuConfigurations.make(popoverAnchorProvider: {
[weak sourceView] in
guard let sourceView else {
return nil
}
return { popover in
popover.sourceView = sourceView
popover.sourceRect = CGRect(origin: point, size: .zero)
}
})
)
}
}
}
================================================
FILE: Sources/JotsPage/JotCell/PageCellItem+jot.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func jot(
jot: JotBusinessModel,
jotMenuConfigurations: JotMenuConfigurations,
sizing: PageCellSizingStrategy,
repository: JotsRepositoryProtocol,
onAction: @Sendable @escaping () -> Void
) -> PageCellItem {
PageCellItem(
id: jot,
cellType: JotCell.self,
sizing: sizing,
viewModel: JotCellViewModel(
jot: jot,
jotMenuConfigurations: jotMenuConfigurations,
repository: repository,
onAction: onAction
)
)
}
}
================================================
FILE: Sources/JotsPage/JotMenuConfiguration.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
enum JotMenuConfiguration {
struct Action {
let title: String
let systemImageName: String
var isDestructive: Bool = false
let handler: @MainActor @Sendable () -> Void
}
struct Group {
let title: String
let systemImageName: String
let actions: [Action]
}
case action(Action)
case group(Group)
}
================================================
FILE: Sources/JotsPage/JotMenuConfigurationFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct JotMenuConfigurations {
private let makeProvider:
@Sendable (_ popoverAnchorProvider: @MainActor @Sendable @escaping () -> PopoverAnchor?) ->
[JotMenuConfiguration]
init(
makeProvider:
@Sendable @escaping (_ popoverAnchorProvider: @MainActor @Sendable @escaping () -> PopoverAnchor?) ->
[JotMenuConfiguration]
) {
self.makeProvider = makeProvider
}
func make(popoverAnchorProvider: @MainActor @Sendable @escaping () -> PopoverAnchor?) -> [JotMenuConfiguration] {
makeProvider(popoverAnchorProvider)
}
}
struct JotMenuConfigurationFactory: Sendable {
func make(
onShare: @Sendable @escaping (ShareFormat, PopoverAnchor?) -> Void,
onRename: @Sendable @escaping () -> Void,
onDuplicate: @Sendable @escaping () -> Void,
onDelete: @Sendable @escaping () -> Void,
onShowInFiles: @Sendable @escaping () -> Void,
onOpenInNewWindow: (@Sendable () -> Void)? = nil
) -> JotMenuConfigurations {
JotMenuConfigurations { popoverAnchorProvider in
var menuConfiguration = [JotMenuConfiguration]()
if let onOpenInNewWindow {
menuConfiguration.append(
.action(
JotMenuConfiguration.Action(
title: L10n.Jots.Menu.openInNewWindow,
systemImageName: "plus.app"
) {
onOpenInNewWindow()
}
)
)
}
menuConfiguration.append(contentsOf: [
.action(
JotMenuConfiguration.Action(
title: L10n.Action.rename,
systemImageName: "pencil"
) {
onRename()
}
),
.action(
JotMenuConfiguration.Action(
title: L10n.Action.duplicate,
systemImageName: "plus.square.on.square"
) {
onDuplicate()
}
),
.action(
JotMenuConfiguration.Action(
title: L10n.Action.delete,
systemImageName: "trash",
isDestructive: true
) {
onDelete()
}
),
.action(
JotMenuConfiguration.Action(
title: {
#if targetEnvironment(macCatalyst)
L10n.Jots.Menu.revealInFinder
#else
L10n.Jots.Menu.revealInFiles
#endif
}(),
systemImageName: "folder"
) {
onShowInFiles()
}
),
.group(
JotMenuConfiguration.Group(
title: L10n.Action.share,
systemImageName: "square.and.arrow.up",
actions: [
JotMenuConfiguration.Action(
title: L10n.Share.Format.pdf,
systemImageName: "doc.fill"
) {
onShare(.pdf, popoverAnchorProvider())
},
JotMenuConfiguration.Action(
title: L10n.Share.Format.jpg,
systemImageName: "photo.fill"
) {
onShare(.jpg, popoverAnchorProvider())
},
JotMenuConfiguration.Action(
title: L10n.Share.Format.png,
systemImageName: "photo"
) {
onShare(.png, popoverAnchorProvider())
},
]
)
),
])
return menuConfiguration
}
}
}
================================================
FILE: Sources/JotsPage/JotsCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol JotsCoordinatorProtocol: NavigationCoordinator {
func openSettings()
func openCreateJot()
func openJot(jotFileInfo: JotFile.Info, prefersNewWindow: Bool)
func openEnableCloudPage()
func showShareJot(
jotFileInfo: JotFile.Info,
format: ShareFormat,
configurePopoverAnchor: PopoverAnchor?
)
func showRenameAlert(jotFileInfo: JotFile.Info)
func openDeleteJot(jotFileInfo: JotFile.Info)
func showInfoAlert(title: String, message: String)
func showInFiles(jotFileInfo: JotFile.Info)
}
@MainActor
final class JotsCoordinator: NavigationCoordinator, JotsCoordinatorProtocol {
private var cloudMigrationTask: Task?
private var retainedInfoAlertCoordinator: Coordinator?
private var retainedShareJotCoordinator: Coordinator?
private var retainedRenameJotCoordinator: Coordinator?
private var retainedDeleteJotCoordinator: Coordinator?
private var retainedCreateJotCoordinator: Coordinator?
private var retainedCloudMigrationCoordinator: Coordinator?
private var retainedRevealFileCoordinator: Coordinator?
private var retainedSettingsCoordinator: Coordinator?
private var retainedEnableCloudCoordinator: Coordinator?
private var retainedJotsViewController: UIViewController?
private lazy var childCoordinators: [NavigationCoordinator] = [
editJotCoordinatorFactory.make(navigation: navigation)
]
private let navigation: Navigation
private let jotsViewControllerFactory: JotsViewControllerFactoryProtocol
private let settingsCoordinatorFactory: SettingsCoordinatorFactoryProtocol
private let enableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryProtocol
private let editJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol
private let cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactoryProtocol
private let createJotCoordinatorFactory: CreateJotCoordinatorFactoryProtocol
private let deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol
private let renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol
private let shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol
private let revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol
init(
navigation: Navigation,
jotsViewControllerFactory: JotsViewControllerFactoryProtocol,
settingsCoordinatorFactory: SettingsCoordinatorFactoryProtocol,
enableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryProtocol,
editJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol,
cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactoryProtocol,
createJotCoordinatorFactory: CreateJotCoordinatorFactoryProtocol,
deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol,
renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol,
shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol,
revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol
) {
self.navigation = navigation
self.jotsViewControllerFactory = jotsViewControllerFactory
self.settingsCoordinatorFactory = settingsCoordinatorFactory
self.enableCloudCoordinatorFactory = enableCloudCoordinatorFactory
self.editJotCoordinatorFactory = editJotCoordinatorFactory
self.cloudMigrationCoordinatorFactory = cloudMigrationCoordinatorFactory
self.createJotCoordinatorFactory = createJotCoordinatorFactory
self.deleteJotCoordinatorFactory = deleteJotCoordinatorFactory
self.renameJotCoordinatorFactory = renameJotCoordinatorFactory
self.shareJotCoordinatorFactory = shareJotCoordinatorFactory
self.revealFileCoordinatorFactory = revealFileCoordinatorFactory
}
func shouldHandle(url: URL) -> Bool {
true
}
func handle(url: URL) -> [UIViewController] {
var viewControllers: [UIViewController]
if let retainedJotsViewController {
viewControllers = [retainedJotsViewController]
} else {
let jotsViewController = jotsViewControllerFactory.make(coordinator: self)
self.retainedJotsViewController = jotsViewController
viewControllers = [jotsViewController]
}
if let childCoordinator = childCoordinators.first(where: { $0.shouldHandle(url: url) }) {
viewControllers.append(contentsOf: childCoordinator.handle(url: url))
return viewControllers
}
showCloudMigrationPageIfNeeded()
return viewControllers
}
func openSettings() {
let settingsCoordinator = settingsCoordinatorFactory.make(navigation: navigation)
retainedSettingsCoordinator = settingsCoordinator
settingsCoordinator.onEnd = { [weak self] in
self?.retainedSettingsCoordinator = nil
}
settingsCoordinator.start()
}
func openCreateJot() {
let createJotCoordinator = createJotCoordinatorFactory.make(navigation: navigation)
retainedCreateJotCoordinator = createJotCoordinator
createJotCoordinator.onEnd = { [weak self] in
self?.retainedCreateJotCoordinator = nil
}
createJotCoordinator.start()
}
func openJot(
jotFileInfo: JotFile.Info,
prefersNewWindow: Bool
) {
let url = EditJotURL(jotFileInfo: jotFileInfo)
#if targetEnvironment(macCatalyst)
navigation.openScene(url: url)
#else
if prefersNewWindow {
navigation.openScene(url: url)
} else {
navigation.open(url: url)
}
#endif
}
func openEnableCloudPage() {
let enableCloudCoordinator = enableCloudCoordinatorFactory.make(navigation: navigation)
retainedEnableCloudCoordinator = enableCloudCoordinator
enableCloudCoordinator.onEnd = { [weak self] in
self?.retainedEnableCloudCoordinator = nil
}
enableCloudCoordinator.start()
}
func showShareJot(
jotFileInfo: JotFile.Info,
format: ShareFormat,
configurePopoverAnchor: PopoverAnchor?
) {
let coordinator = shareJotCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
format: format,
navigation: navigation,
configurePopoverAnchor: configurePopoverAnchor
)
retainedShareJotCoordinator = coordinator
coordinator.onEnd = { [weak self] in
self?.retainedShareJotCoordinator = nil
}
coordinator.start()
}
func showRenameAlert(jotFileInfo: JotFile.Info) {
let coordinator = renameJotCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
navigation: navigation
) { _ in
/* no-op */
}
retainedRenameJotCoordinator = coordinator
coordinator.onEnd = { [weak self] in
self?.retainedRenameJotCoordinator = nil
}
coordinator.start()
}
func openDeleteJot(jotFileInfo: JotFile.Info) {
let deleteJotCoordinator = deleteJotCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
navigation: navigation
)
retainedDeleteJotCoordinator = deleteJotCoordinator
deleteJotCoordinator.onEnd = { [weak self] in
self?.retainedDeleteJotCoordinator = nil
}
deleteJotCoordinator.start()
}
func showInfoAlert(
title: String,
message: String
) {
let infoAlertCoordinator = InfoAlertCoordinator(
navigation: navigation,
title: title,
message: message
)
retainedInfoAlertCoordinator = infoAlertCoordinator
infoAlertCoordinator.onEnd = { [weak self] in
self?.retainedInfoAlertCoordinator = nil
}
infoAlertCoordinator.start()
}
func showInFiles(jotFileInfo: JotFile.Info) {
let revealFileCoordinator = revealFileCoordinatorFactory.make(
jotFileInfo: jotFileInfo,
navigation: navigation
)
retainedRevealFileCoordinator = revealFileCoordinator
revealFileCoordinator.onEnd = { [weak self] in
self?.retainedRevealFileCoordinator = nil
}
revealFileCoordinator.start()
}
private func showCloudMigrationPageIfNeeded() {
let cloudMigrationCoordinator = cloudMigrationCoordinatorFactory.make(navigation: navigation)
guard cloudMigrationCoordinator.shouldStart() else {
return
}
retainedCloudMigrationCoordinator = cloudMigrationCoordinator
cloudMigrationCoordinator.onEnd = { [weak self] in
self?.retainedCloudMigrationCoordinator = nil
}
cloudMigrationCoordinator.start()
}
deinit {
cloudMigrationTask?.cancel()
}
}
================================================
FILE: Sources/JotsPage/JotsCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol JotsCoordinatorFactoryProtocol: Sendable {
func make(navigation: Navigation) -> NavigationCoordinator
}
struct JotsCoordinatorFactory: JotsCoordinatorFactoryProtocol {
let jotsViewControllerFactory: JotsViewControllerFactory
let settingsCoordinatorFactory: SettingsCoordinatorFactory
let enableCloudCoordinatorFactory: EnableCloudCoordinatorFactory
let editJotCoordinatorFactory: EditJotCoordinatorFactory
let cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactory
let createJotCoordinatorFactory: CreateJotCoordinatorFactoryProtocol
let deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol
let renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol
let shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol
let revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol
func make(navigation: Navigation) -> NavigationCoordinator {
JotsCoordinator(
navigation: navigation,
jotsViewControllerFactory: jotsViewControllerFactory,
settingsCoordinatorFactory: settingsCoordinatorFactory,
enableCloudCoordinatorFactory: enableCloudCoordinatorFactory,
editJotCoordinatorFactory: editJotCoordinatorFactory,
cloudMigrationCoordinatorFactory: cloudMigrationCoordinatorFactory,
createJotCoordinatorFactory: createJotCoordinatorFactory,
deleteJotCoordinatorFactory: deleteJotCoordinatorFactory,
renameJotCoordinatorFactory: renameJotCoordinatorFactory,
shareJotCoordinatorFactory: shareJotCoordinatorFactory,
revealFileCoordinatorFactory: revealFileCoordinatorFactory
)
}
}
================================================
FILE: Sources/JotsPage/JotsPageURL.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
struct JotsPageURL: URLConvertible {
let path = "/"
}
================================================
FILE: Sources/JotsPage/JotsRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import UIKit
protocol JotsRepositoryProtocol: Sendable {
func getJotFiles() -> AsyncThrowingStream<[JotFile.Info], Error>
func shouldShowEnableICloudButton() -> Bool
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info
func download(jotFileInfo: JotFile.Info) throws
func getPreviewImage(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage?
@MainActor
func supportsMultipleScenes() -> Bool
@MainActor
func isIPadOS() -> Bool
}
struct JotsRepository: JotsRepositoryProtocol {
private let ubiquitousFileService: FileServiceProtocol
private let applicationService: ApplicationServiceProtocol
private let deviceService: DeviceServiceProtocol
private let jotFileService: JotFileServiceProtocol
private let jotFilePreviewImageService: JotFilePreviewImageServiceProtocol
init(
ubiquitousFileService: FileServiceProtocol,
applicationService: ApplicationServiceProtocol,
deviceService: DeviceServiceProtocol,
jotFileService: JotFileServiceProtocol,
jotFilePreviewImageService: JotFilePreviewImageServiceProtocol
) {
self.ubiquitousFileService = ubiquitousFileService
self.applicationService = applicationService
self.deviceService = deviceService
self.jotFileService = jotFileService
self.jotFilePreviewImageService = jotFilePreviewImageService
}
func getJotFiles() -> AsyncThrowingStream<[JotFile.Info], Error> {
jotFileService
.documentsDirectoryContents()
.map { jotFileInfos in
jotFileInfos.sorted { a, b in
(a.modificationDate ?? .distantPast) > (b.modificationDate ?? .distantPast)
}
}
.toAsyncThrowingStream()
}
func shouldShowEnableICloudButton() -> Bool {
!ubiquitousFileService.isEnabled()
}
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info {
try jotFileService.duplicate(jotFileInfo: jotFileInfo)
}
func download(jotFileInfo: JotFile.Info) throws {
try ubiquitousFileService.startDownload(fileURL: jotFileInfo.url)
}
func getPreviewImage(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
do {
let imageData = try await jotFilePreviewImageService.getPreviewImageData(
jotFileInfo: jotFileInfo,
userInterfaceStyle: userInterfaceStyle,
displayScale: displayScale
)
return UIImage(data: imageData)
} catch {
return nil
}
}
func supportsMultipleScenes() -> Bool {
applicationService.supportsMultipleScenes()
}
func isIPadOS() -> Bool {
deviceService.isIPadOS()
}
}
================================================
FILE: Sources/JotsPage/JotsViewControllerFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol JotsViewControllerFactoryProtocol: Sendable {
func make(coordinator: JotsCoordinatorProtocol) -> UIViewController
}
struct JotsViewControllerFactory: JotsViewControllerFactoryProtocol {
let repository: JotsRepositoryProtocol
let menuConfigurationFactory: JotMenuConfigurationFactory
let textBarButtonItemFactory: TextBarButtonItemFactory
let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
let logger: LoggerProtocol
func make(coordinator: JotsCoordinatorProtocol) -> UIViewController {
let viewController = PageViewController(
viewModel: JotsViewModel(
coordinator: coordinator,
repository: repository,
menuConfigurationFactory: menuConfigurationFactory,
logger: logger
),
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
)
#if targetEnvironment(macCatalyst)
viewController.navigationItem.largeTitleDisplayMode = .never
#else
viewController.navigationItem.largeTitleDisplayMode = .always
#endif
return viewController
}
}
================================================
FILE: Sources/JotsPage/JotsViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
final class JotsViewModel: PageViewModel {
var title: String? {
#if targetEnvironment(macCatalyst)
nil
#else
L10n.App.title
#endif
}
let leftNavigationItems: AsyncStream<[PageNavigationItem]>
private let leftNavigationItemsContinuation: AsyncStream<[PageNavigationItem]>.Continuation
let rightNavigationItems: AsyncStream<[PageNavigationItem]>
private let rightNavigationItemsContinuation: AsyncStream<[PageNavigationItem]>.Continuation
let items: AsyncStream<[PageCellItem]>
private let itemsContinuation: AsyncStream<[PageCellItem]>.Continuation
let actions = [PageCallToActionView.ActionConfiguration]()
private var jotsTask: Task?
private weak var coordinator: JotsCoordinatorProtocol?
private let repository: JotsRepositoryProtocol
private let menuConfigurationFactory: JotMenuConfigurationFactory
private let logger: LoggerProtocol
init(
coordinator: JotsCoordinatorProtocol,
repository: JotsRepositoryProtocol,
menuConfigurationFactory: JotMenuConfigurationFactory,
logger: LoggerProtocol
) {
self.coordinator = coordinator
self.repository = repository
self.menuConfigurationFactory = menuConfigurationFactory
self.logger = logger
(items, itemsContinuation) = AsyncStream.makeStream(
of: [PageCellItem].self,
bufferingPolicy: .bufferingNewest(1)
)
(leftNavigationItems, leftNavigationItemsContinuation) = AsyncStream.makeStream(
of: [PageNavigationItem].self,
bufferingPolicy: .bufferingNewest(1)
)
var leftNavigationItems = [
PageNavigationItem.symbol(
systemImageName: "gear",
) { [weak coordinator] in
Task { @MainActor in
coordinator?.openSettings()
}
}
]
if repository.shouldShowEnableICloudButton() {
leftNavigationItems.append(
.symbol(
systemImageName: "icloud.slash",
) { [weak coordinator] in
Task { @MainActor in
coordinator?.openEnableCloudPage()
}
}
)
}
leftNavigationItemsContinuation.yield(leftNavigationItems)
(rightNavigationItems, rightNavigationItemsContinuation) = AsyncStream.makeStream(
of: [PageNavigationItem].self,
bufferingPolicy: .bufferingNewest(1)
)
#if !targetEnvironment(macCatalyst)
rightNavigationItemsContinuation.yield([
.text(
title: L10n.Action.create
) { [weak coordinator] in
Task { @MainActor in
coordinator?.openCreateJot()
}
}
])
#endif
}
func didLoad() {
jotsTask = Task { [weak self] in
guard let self else {
return
}
do {
for try await jotFileInfos in repository.getJotFiles() {
handleJots(jotFileInfos: jotFileInfos)
}
} catch {
logger.error("Failed to observe jot files: \(error)")
}
}
}
private func handleJots(jotFileInfos: [JotFile.Info]) {
guard !jotFileInfos.isEmpty else {
itemsContinuation.yield([
.jotsEmptyState(title: L10n.Jots.Empty.title)
])
return
}
let supportsMultipleScenes = repository.supportsMultipleScenes()
itemsContinuation.yield(
jotFileInfos.map { jotFileInfo in
let jot = JotBusinessModel(jotFileInfo: jotFileInfo)
return .jot(
jot: jot,
jotMenuConfigurations: makeMenuConfigurations(
jotFileInfo: jotFileInfo,
supportsMultipleScenes: supportsMultipleScenes
),
sizing: .adaptiveGrid(
minColumns: 2,
maxColumns: 8,
minItemWidth: 160,
maxItemWidth: 200,
columnSpacing: DesignTokens.Spacing.md,
rowSpacing: DesignTokens.Spacing.md,
aspectRatio: CGSize(
width: 7,
height: 8
)
),
repository: repository,
onAction: { [weak coordinator, weak self] in
Task { @MainActor in
guard let self else {
return
}
if jot.isDownloaded {
coordinator?.openJot(
jotFileInfo: jotFileInfo,
prefersNewWindow: !self.repository.isIPadOS()
)
} else {
do {
try self.repository.download(jotFileInfo: jotFileInfo)
} catch {
coordinator?.showInfoAlert(
title: L10n.Jots.Download.Error.generic(jotFileInfo.name),
message: error.localizedDescription
)
}
}
}
}
)
}
)
}
private func makeMenuConfigurations(
jotFileInfo: JotFile.Info,
supportsMultipleScenes: Bool
) -> JotMenuConfigurations {
menuConfigurationFactory.make(
onShare: { [weak coordinator] format, configurePopoverAnchor in
Task { @MainActor in
coordinator?.showShareJot(
jotFileInfo: jotFileInfo,
format: format,
configurePopoverAnchor: configurePopoverAnchor
)
}
},
onRename: { [weak coordinator] in
Task { @MainActor in
coordinator?.showRenameAlert(jotFileInfo: jotFileInfo)
}
},
onDuplicate: { [weak self] in
Task { @MainActor in
self?.didTapDuplicateJot(jotFileInfo: jotFileInfo)
}
},
onDelete: { [weak coordinator] in
Task { @MainActor in
coordinator?.openDeleteJot(jotFileInfo: jotFileInfo)
}
},
onShowInFiles: { [weak coordinator] in
Task { @MainActor in
coordinator?.showInFiles(jotFileInfo: jotFileInfo)
}
},
onOpenInNewWindow: supportsMultipleScenes
? { @Sendable [weak coordinator] in
Task { @MainActor in
coordinator?.openJot(
jotFileInfo: jotFileInfo,
prefersNewWindow: true
)
}
} : nil
)
}
private func didTapDuplicateJot(jotFileInfo: JotFile.Info) {
do {
_ = try repository.duplicate(jotFileInfo: jotFileInfo)
} catch {
coordinator?.showInfoAlert(
title: L10n.Jots.Duplicate.Error.generic(jotFileInfo.name),
message: error.localizedDescription
)
}
}
deinit {
jotsTask?.cancel()
}
}
================================================
FILE: Sources/JotsPage/RenameJot/RenameJotCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class RenameJotCoordinator: Coordinator {
private var retainedInfoAlertCoordinator: Coordinator?
var onEnd: (() -> Void)?
private let jotFileInfo: JotFile.Info
private let navigation: Navigation
private let repository: RenameJotRepositoryProtocol
private let onRename: @Sendable (_ info: JotFile.Info) -> Void
init(
jotFileInfo: JotFile.Info,
navigation: Navigation,
repository: RenameJotRepositoryProtocol,
onRename: @Sendable @escaping (_ info: JotFile.Info) -> Void
) {
self.jotFileInfo = jotFileInfo
self.navigation = navigation
self.repository = repository
self.onRename = onRename
}
func start() {
let alertController = UIAlertController(
title: L10n.Jots.Rename.title,
message: nil,
preferredStyle: .alert
)
alertController.addTextField { [weak self] textField in
textField.clearButtonMode = .whileEditing
textField.placeholder = self?.jotFileInfo.name
}
alertController.addAction(
UIAlertAction(
title: L10n.Action.cancel,
style: .cancel,
handler: { [weak self] _ in
self?.onEnd?()
}
)
)
alertController.addAction(
UIAlertAction(
title: L10n.Action.rename,
style: .default
) { [weak self] _ in
guard
let self,
let newName = alertController.textFields?.first?.text
else {
self?.onEnd?()
return
}
handleRename(newName: newName)
}
)
navigation.present(alertController, animated: true)
}
private func handleRename(
newName: String
) {
do {
let renamedJotFileInfo = try repository.rename(
jotFileInfo: jotFileInfo,
newName: newName
)
onRename(renamedJotFileInfo)
onEnd?()
} catch {
showInfoAlert(
title: L10n.Jots.Rename.Error.generic(newName),
message: error.localizedDescription
)
}
}
private func showInfoAlert(
title: String,
message: String
) {
let infoAlertCoordinator = InfoAlertCoordinator(
navigation: navigation,
title: title,
message: message
)
retainedInfoAlertCoordinator = infoAlertCoordinator
infoAlertCoordinator.onEnd = { [weak self] in
self?.retainedInfoAlertCoordinator = nil
self?.onEnd?()
}
infoAlertCoordinator.start()
}
}
================================================
FILE: Sources/JotsPage/RenameJot/RenameJotCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol RenameJotCoordinatorFactoryProtocol: Sendable {
func make(
jotFileInfo: JotFile.Info,
navigation: Navigation,
onRename: @Sendable @escaping (_ renameJotFileInfo: JotFile.Info) -> Void
) -> Coordinator
}
struct RenameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol {
let repository: RenameJotRepositoryProtocol
func make(
jotFileInfo: JotFile.Info,
navigation: Navigation,
onRename: @Sendable @escaping (_ renameJotFileInfo: JotFile.Info) -> Void
) -> Coordinator {
RenameJotCoordinator(
jotFileInfo: jotFileInfo,
navigation: navigation,
repository: repository,
onRename: onRename
)
}
}
================================================
FILE: Sources/JotsPage/RenameJot/RenameJotRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
protocol RenameJotRepositoryProtocol {
func rename(jotFileInfo: JotFile.Info, newName: String) throws -> JotFile.Info
}
struct RenameJotRepository: RenameJotRepositoryProtocol {
private let jotFileService: JotFileServiceProtocol
init(jotFileService: JotFileServiceProtocol) {
self.jotFileService = jotFileService
}
func rename(jotFileInfo: JotFile.Info, newName: String) throws -> JotFile.Info {
try jotFileService.rename(
jotFileInfo: jotFileInfo,
newName: newName
)
}
}
================================================
FILE: Sources/JotsPage/ShareJot/ShareJotCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
enum ShareFormat: Sendable {
case pdf, jpg, png
}
typealias PopoverAnchor = @MainActor @Sendable (UIPopoverPresentationController) -> Void
final class ShareJotCoordinator: Coordinator {
var onEnd: (() -> Void)?
private var retainedInfoAlertCoordinator: Coordinator?
private var exportTask: Task?
private let jotFileInfo: JotFile.Info
private let format: ShareFormat
private let navigation: Navigation
private let repository: ShareJotRepositoryProtocol
private let configurePopoverAnchor: PopoverAnchor?
init(
jotFileInfo: JotFile.Info,
format: ShareFormat,
navigation: Navigation,
repository: ShareJotRepositoryProtocol,
configurePopoverAnchor: PopoverAnchor?
) {
self.jotFileInfo = jotFileInfo
self.format = format
self.navigation = navigation
self.repository = repository
self.configurePopoverAnchor = configurePopoverAnchor
}
func start() {
exportTask = Task { [weak self] in
guard let self else {
return
}
do {
let fileURL = try await repository.exportJot(
jotFileInfo: jotFileInfo,
format: format
)
presentActivityViewController(fileURL: fileURL)
} catch {
showInfoAlert(
title: L10n.Jots.Share.Error.generic(jotFileInfo.name),
message: error.localizedDescription
)
}
}
}
private func presentActivityViewController(fileURL: URL) {
let activityViewController = UIActivityViewController(
activityItems: [fileURL],
applicationActivities: nil
)
if let popoverPresentationController = activityViewController.popoverPresentationController {
guard let configurePopoverAnchor else {
assertionFailure("PopoverAnchor must be provided.")
onEnd?()
return
}
configurePopoverAnchor(popoverPresentationController)
}
activityViewController.completionWithItemsHandler = { [weak self] _, _, _, _ in
self?.onEnd?()
}
navigation.present(activityViewController, animated: true)
}
private func showInfoAlert(
title: String,
message: String?
) {
let infoAlertCoordinator = InfoAlertCoordinator(
navigation: navigation,
title: title,
message: message
)
retainedInfoAlertCoordinator = infoAlertCoordinator
infoAlertCoordinator.onEnd = { [weak self] in
self?.retainedInfoAlertCoordinator = nil
self?.onEnd?()
}
infoAlertCoordinator.start()
}
deinit {
exportTask?.cancel()
}
}
================================================
FILE: Sources/JotsPage/ShareJot/ShareJotCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol ShareJotCoordinatorFactoryProtocol: Sendable {
func make(
jotFileInfo: JotFile.Info,
format: ShareFormat,
navigation: Navigation,
configurePopoverAnchor: PopoverAnchor?
) -> Coordinator
}
struct ShareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol {
let repository: ShareJotRepositoryProtocol
func make(
jotFileInfo: JotFile.Info,
format: ShareFormat,
navigation: Navigation,
configurePopoverAnchor: PopoverAnchor?
) -> Coordinator {
ShareJotCoordinator(
jotFileInfo: jotFileInfo,
format: format,
navigation: navigation,
repository: repository,
configurePopoverAnchor: configurePopoverAnchor
)
}
}
================================================
FILE: Sources/JotsPage/ShareJot/ShareJotRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@preconcurrency import PencilKit
import UIKit
protocol ShareJotRepositoryProtocol: Sendable {
func exportJot(
jotFileInfo: JotFile.Info,
format: ShareFormat
) async throws -> URL
}
struct ShareJotRepository: ShareJotRepositoryProtocol {
private enum Constants {
enum Rendering {
static let scale = CGFloat(2)
}
enum JpegEncoding {
static let compressionQuality = CGFloat(0.9)
}
}
enum Failure: Error {
case couldNotRenderImage
}
private let jotFileService: JotFileServiceProtocol
private let fileService: FileServiceProtocol
init(
jotFileService: JotFileServiceProtocol,
fileService: FileServiceProtocol
) {
self.jotFileService = jotFileService
self.fileService = fileService
}
func exportJot(
jotFileInfo: JotFile.Info,
format: ShareFormat
) async throws -> URL {
let jotFile = try jotFileService.readJotFile(jotFileInfo: jotFileInfo)
let drawing = try PKDrawing(data: jotFile.jot.drawing)
let width = jotFile.jot.width
let contentHeight = max(drawing.bounds.maxY, width * sqrt(2))
let rect = CGRect(x: 0, y: 0, width: width, height: contentHeight)
let temporaryDirectory = fileService.temporaryDirectory()
let fileName = jotFileInfo.name
switch format {
case .pdf:
return try await exportPDF(
drawing: drawing,
rect: rect,
url: temporaryDirectory.appendingPathComponent("\(fileName).pdf")
)
case .jpg:
return try await exportJPG(
drawing: drawing,
rect: rect,
url: temporaryDirectory.appendingPathComponent("\(fileName).jpg")
)
case .png:
return try await exportPNG(
drawing: drawing,
rect: rect,
url: temporaryDirectory.appendingPathComponent("\(fileName).png")
)
}
}
private func exportPDF(
drawing: PKDrawing,
rect: CGRect,
url: URL
) async throws -> URL {
let data = await MainActor.run {
let renderer = UIGraphicsPDFRenderer(bounds: rect)
return renderer.pdfData { context in
context.beginPage()
let image = renderDrawing(drawing: drawing, rect: rect)
image.draw(in: rect)
}
}
try data.write(to: url)
return url
}
private func exportJPG(
drawing: PKDrawing,
rect: CGRect,
url: URL
) async throws -> URL {
let data: Data = try await MainActor.run {
let renderer = UIGraphicsImageRenderer(bounds: rect)
let image = renderer.image { context in
UIColor.white.setFill()
context.fill(rect)
renderDrawing(drawing: drawing, rect: rect).draw(in: rect)
}
guard let jpegData = image.jpegData(compressionQuality: Constants.JpegEncoding.compressionQuality) else {
throw Failure.couldNotRenderImage
}
return jpegData
}
try data.write(to: url)
return url
}
private func exportPNG(
drawing: PKDrawing,
rect: CGRect,
url: URL
) async throws -> URL {
let data: Data = try await MainActor.run {
let image = renderDrawing(drawing: drawing, rect: rect)
guard let pngData = image.pngData() else {
throw Failure.couldNotRenderImage
}
return pngData
}
try data.write(to: url)
return url
}
@MainActor
private func renderDrawing(drawing: PKDrawing, rect: CGRect) -> UIImage {
var image = UIImage()
UITraitCollection(userInterfaceStyle: .light).performAsCurrent {
image = drawing.image(from: rect, scale: Constants.Rendering.scale)
}
return image
}
}
================================================
FILE: Sources/JotsPage/UIMenu+makeJotMenuConfiguration.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
extension UIMenu {
static func make(jotMenuConfigurations: [JotMenuConfiguration]) -> UIMenu {
UIMenu(
children: jotMenuConfigurations.map { configuration in
switch configuration {
case let .action(action):
return UIAction(
title: action.title,
image: UIImage(systemName: action.systemImageName),
attributes: action.isDestructive ? .destructive : []
) { _ in action.handler() }
case let .group(group):
return UIMenu(
title: group.title,
image: UIImage(systemName: group.systemImageName),
children: group.actions.map { action in
UIAction(
title: action.title,
image: UIImage(systemName: action.systemImageName),
attributes: action.isDestructive ? .destructive : []
) { _ in action.handler() }
}
)
}
}
)
}
}
================================================
FILE: Sources/L10n.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
enum L10n {
enum Action {
static let cancel = String(localized: "action.cancel")
static let create = String(localized: "action.create")
static let delete = String(localized: "action.delete")
static let done = String(localized: "action.done")
static let duplicate = String(localized: "action.duplicate")
static let ok = String(localized: "action.ok")
static let rename = String(localized: "action.rename")
static let share = String(localized: "action.share")
}
enum App {
static let title = String(localized: "app.title")
}
enum CloudMigration {
static let subtitle = String(localized: "cloudMigration.subtitle")
static let title = String(localized: "cloudMigration.title")
enum NothingToMigrate {
static let subtitle = String(localized: "cloudMigration.nothingToMigrate.subtitle")
}
enum ErrorAlert {
static func title(_ jotName: String) -> String {
String(format: String(localized: "cloudMigration.errorAlert.title"), jotName)
}
}
}
enum EnableCloud {
static let subtitle = String(localized: "enableCloud.subtitle")
static let title = String(localized: "enableCloud.title")
enum Action {
static let learnHowToEnable = String(localized: "enableCloud.action.learnHowToEnable")
}
enum Feature {
static let share = String(localized: "enableCloud.feature.share")
static let sync = String(localized: "enableCloud.feature.sync")
}
}
enum JotConflict {
static let deviceLabel = String(localized: "jotConflict.deviceLabel")
static let title = String(localized: "jotConflict.title")
enum Error {
static let generic = String(localized: "jotConflict.error.generic")
}
static func subtitle(_ jotName: String) -> String {
String(format: String(localized: "jotConflict.subtitle"), jotName)
}
static func versionName(_ version: String) -> String {
String(format: String(localized: "jotConflict.versionName"), version)
}
enum Action {
static let keepAll = String(localized: "jotConflict.action.keepAll")
static func keepVersion(_ version: String) -> String {
String(format: String(localized: "jotConflict.action.keepVersion"), version)
}
}
}
enum Jots {
enum Empty {
static let title = String(localized: "jots.empty.title")
}
enum Create {
static let namePlaceholder = String(localized: "jots.create.namePlaceholder")
static let title = String(localized: "jots.create.title")
enum Error {
static let generic = String(localized: "jots.create.error.generic")
static func fileExists(_ jotName: String) -> String {
String(format: String(localized: "jots.create.error.fileExists"), jotName)
}
}
}
enum Delete {
static let message = String(localized: "jots.delete.message")
static let title = String(localized: "jots.delete.title")
enum Error {
static func generic(_ jotName: String) -> String {
String(format: String(localized: "jots.delete.error.generic"), jotName)
}
}
}
enum Menu {
static let openInNewWindow = String(localized: "jots.menu.openInNewWindow")
static let revealInFiles = String(localized: "jots.menu.revealInFiles")
static let revealInFinder = String(localized: "jots.menu.revealInFinder")
}
enum Download {
enum Error {
static func generic(_ jotName: String) -> String {
String(format: String(localized: "jots.download.error.generic"), jotName)
}
}
}
enum Duplicate {
enum Error {
static func generic(_ jotName: String) -> String {
String(format: String(localized: "jots.duplicate.error.generic"), jotName)
}
}
}
enum Rename {
static let title = String(localized: "jots.rename.title")
enum Error {
static func generic(_ jotName: String) -> String {
String(format: String(localized: "jots.rename.error.generic"), jotName)
}
}
}
enum Share {
enum Error {
static func generic(_ jotName: String) -> String {
String(format: String(localized: "jots.share.error.generic"), jotName)
}
}
}
}
enum Settings {
static let title = String(localized: "settings.title")
enum Appearance {
static let dark = String(localized: "settings.appearance.dark")
static let light = String(localized: "settings.appearance.light")
static let system = String(localized: "settings.appearance.system")
static let title = String(localized: "settings.appearance.title")
}
enum Github {
static let title = String(localized: "settings.github.title")
}
enum ICloud {
static let info = String(localized: "settings.icloud.info")
static let title = String(localized: "settings.icloud.title")
}
enum Version {
static let title = String(localized: "settings.version.title")
}
}
enum Share {
enum Format {
static let jpg = String(localized: "share.format.jpg")
static let pdf = String(localized: "share.format.pdf")
static let png = String(localized: "share.format.png")
}
}
enum FileSystem {
enum Duplicate {
enum FileName {
static func plain(_ jotName: String) -> String {
String(format: String(localized: "filesystem.duplicate.filename.plain"), jotName)
}
static func multi(_ jotName: String, _ duplicateCount: Int) -> String {
String(format: String(localized: "filesystem.duplicate.filename.multi"), jotName, duplicateCount)
}
}
}
}
}
================================================
FILE: Sources/MacCatalystAppKitPluginService.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
#if targetEnvironment(macCatalyst)
@available(macCatalyst, introduced: 15.0)
struct MacCatalystAppKitPluginService: Sendable {
private let bridgeKlass: AnyClass
init?(bundle: Bundle) {
guard
let url = bundle.url(forResource: "AppKitPlugin", withExtension: "bundle"),
let bridge = Bundle(url: url),
bridge.load(),
let bridgeKlass = NSClassFromString("AppKitPlugin.AppKitPlugin")
else {
return nil
}
self.bridgeKlass = bridgeKlass
}
func terminate() {
let selector = NSSelectorFromString("terminate")
guard (bridgeKlass as AnyObject).responds(to: selector) else {
return
}
_ = (bridgeKlass as AnyObject).perform(selector)
}
}
#endif
================================================
FILE: Sources/Navigation/Coordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@MainActor
protocol Coordinator: Sendable, AnyObject {
var onEnd: (() -> Void)? { get set }
func start()
}
================================================
FILE: Sources/Navigation/Navigation.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
/// Defines navigation actions.
struct Navigation: Sendable {
private let openURLProvider: @Sendable (_ url: URL) -> Void
private let openExternalURLProvider: @Sendable (_ url: URL) -> Void
private let openSceneProvider: @Sendable (_ url: URL) -> Void
private let presentViewControllerProvider:
@Sendable (
_ viewController: UIViewController,
_ animated: Bool
) -> Void
private let dismissViewControllerProvider:
@Sendable (_ animated: Bool, _ completion: (@Sendable () -> Void)?) -> Void
private let popViewControllerProvider: @Sendable (_ animated: Bool) -> Void
private let getViewControllersProvider: @MainActor () -> [UIViewController]
init(
openURLProvider: @Sendable @escaping (_ url: URL) -> Void,
openExternalURLProvider: @Sendable @escaping (_ url: URL) -> Void,
openSceneProvider: @Sendable @escaping (_ url: URL) -> Void,
presentViewControllerProvider:
@Sendable @escaping (
_ viewController: UIViewController,
_ animated: Bool
) -> Void,
dismissViewControllerProvider:
@Sendable @escaping (_ animated: Bool, _ completion: (@Sendable () -> Void)?) ->
Void,
popViewControllerProvider: @Sendable @escaping (_ animated: Bool) -> Void,
getViewControllersProvider: @MainActor @escaping () -> [UIViewController]
) {
self.openURLProvider = openURLProvider
self.openExternalURLProvider = openExternalURLProvider
self.openSceneProvider = openSceneProvider
self.presentViewControllerProvider = presentViewControllerProvider
self.dismissViewControllerProvider = dismissViewControllerProvider
self.popViewControllerProvider = popViewControllerProvider
self.getViewControllersProvider = getViewControllersProvider
}
func open(url: URL) {
openURLProvider(url)
}
func open(url: T) {
openURLProvider(url.toURL())
}
func openExternal(url: URL) {
openExternalURLProvider(url)
}
func openScene(url: URL) {
openSceneProvider(url)
}
func openScene(url: T) {
openSceneProvider(url.toURL())
}
func present(_ viewController: UIViewController, animated: Bool) {
presentViewControllerProvider(viewController, animated)
}
func dismiss(animated: Bool, completion: (@Sendable () -> Void)? = nil) {
dismissViewControllerProvider(animated, completion)
}
func popViewController(animated: Bool) {
popViewControllerProvider(animated)
}
@MainActor
func getViewControllers() -> [UIViewController] {
getViewControllersProvider()
}
}
================================================
FILE: Sources/Navigation/NavigationCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol NavigationCoordinator: Sendable, AnyObject {
/// Whether this coordinator is capable of navigating to the given ``URL``.
func shouldHandle(url: URL) -> Bool
/// Performs the navigation action this coordinator provides for the given ``URL``.
func handle(url: URL) -> [UIViewController]
}
================================================
FILE: Sources/Navigation/URLConvertible.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
/// Allows conformances to be converted to a ``URL``.
protocol URLConvertible: Sendable {
var scheme: String? { get }
var host: String? { get }
var path: String { get }
var queryItems: [URLQueryItem] { get }
func toURL() -> URL
}
extension URLConvertible {
var scheme: String? {
nil
}
var host: String? {
nil
}
var queryItems: [URLQueryItem] {
[]
}
func toURL() -> URL {
assert(path.starts(with: "/"))
var components = URLComponents()
components.scheme = scheme
components.host = host
components.path = path
if !queryItems.isEmpty {
components.queryItems = queryItems
}
guard let url = components.url else {
preconditionFailure()
}
return url
}
}
================================================
FILE: Sources/PageViewController/Cell/PageCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol PageCell: UICollectionViewCell {
static var reuseIdentifier: String { get }
associatedtype ViewModel: Sendable & AnyObject
func configure(viewModel: ViewModel)
}
================================================
FILE: Sources/PageViewController/Cell/PageCellAction.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
enum PageCellAction: Sendable {
case tap
}
================================================
FILE: Sources/PageViewController/Cell/PageCellItem.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
struct PageCellItem: Sendable, Hashable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.hashValue == rhs.hashValue
}
let id: any Hashable & Sendable
let cellType: any PageCell.Type
let sizing: PageCellSizingStrategy
let configure: @Sendable @MainActor (_ cell: Any) -> Void
let handleAction: @Sendable @MainActor (_ action: PageCellAction) -> Void
let contextMenuConfiguration:
@Sendable @MainActor (_ point: CGPoint, _ sourceView: UIView) -> UIContextMenuConfiguration?
init<
Cell: PageCell,
ViewModel: PageCellViewModel
>(
id: some Hashable & Sendable,
cellType: Cell.Type,
sizing: PageCellSizingStrategy,
viewModel: @MainActor @autoclosure @escaping () -> ViewModel
) where Cell.ViewModel == ViewModel {
self.id = id
self.cellType = Cell.self
self.sizing = sizing
var retainedViewModel: ViewModel?
let getViewModel: @MainActor () -> ViewModel = {
guard let retainedViewModel else {
let viewModel = viewModel()
retainedViewModel = viewModel
return viewModel
}
return retainedViewModel
}
configure = { cell in
guard let cell = cell as? Cell else {
assertionFailure("Expected '\(Cell.self)' but received '\(type(of: cell))'.")
return
}
cell.configure(viewModel: getViewModel())
}
handleAction = { getViewModel().handle(action: $0) }
contextMenuConfiguration = { point, sourceView in
getViewModel().handleContextMenuConfiguration(point: point, sourceView: sourceView)
}
}
func hash(into hasher: inout Hasher) {
hasher.combine(id.hashValue)
}
}
================================================
FILE: Sources/PageViewController/Cell/PageCellSizingStrategy.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
enum PageCellSizingStrategy: Sendable, Hashable {
case fullWidth(
estimatedHeight: CGFloat = 120,
rowSpacing: CGFloat = DesignTokens.Spacing.xs
)
case equalSplit(
perRow: Int,
itemHeight: CGFloat,
columnSpacing: CGFloat = DesignTokens.Spacing.sm,
rowSpacing: CGFloat = DesignTokens.Spacing.sm
)
case adaptiveGrid(
minColumns: Int,
maxColumns: Int,
minItemWidth: CGFloat,
maxItemWidth: CGFloat,
columnSpacing: CGFloat,
rowSpacing: CGFloat,
aspectRatio: CGSize
)
var columnSpacing: CGFloat {
switch self {
case .fullWidth:
return .zero
case let .equalSplit(_, _, columnSpacing, _):
return columnSpacing
case let .adaptiveGrid(_, _, _, _, columnSpacing, _, _):
return columnSpacing
}
}
var rowSpacing: CGFloat {
switch self {
case let .fullWidth(_, rowSpacing):
return rowSpacing
case let .equalSplit(_, _, _, rowSpacing):
return rowSpacing
case let .adaptiveGrid(_, _, _, _, _, rowSpacing, _):
return rowSpacing
}
}
}
================================================
FILE: Sources/PageViewController/Cell/PageCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol PageCellViewModel: Sendable {
func handle(action: PageCellAction)
func handleContextMenuConfiguration(
point: CGPoint,
sourceView: UIView
) -> UIContextMenuConfiguration?
}
extension PageCellViewModel {
func handleContextMenuConfiguration(
point: CGPoint,
sourceView: UIView
) -> UIContextMenuConfiguration? {
nil
}
}
================================================
FILE: Sources/PageViewController/PageCallToActionView.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class PageCallToActionView: UIStackView {
private enum Constants {
static let height = CGFloat(50)
}
struct ActionConfiguration {
enum Style {
case primary
case secondary
}
let style: Style
let title: String
let icon: String?
let action: () -> Void
}
init(actions: [ActionConfiguration]) {
super.init(frame: .zero)
axis = .vertical
spacing = DesignTokens.Spacing.xs
for action in actions {
addArrangedSubview(makeButton(action: action))
}
}
@available(*, unavailable)
required init(coder: NSCoder) {
fatalError("\(#function) has not been implemented")
}
override func layoutSubviews() {
if let windowBounds = window?.bounds, windowBounds.width > windowBounds.height {
axis = .horizontal
distribution = .fillEqually
} else {
axis = .vertical
distribution = .fill
}
super.layoutSubviews()
}
private func makeButton(action: ActionConfiguration) -> UIButton {
var configuration = UIButton.Configuration.filled()
switch action.style {
case .primary:
configuration.baseBackgroundColor = .label
configuration.baseForegroundColor = .systemBackground
case .secondary:
configuration.baseBackgroundColor = .secondarySystemGroupedBackground
configuration.baseForegroundColor = .label
}
configuration.title = action.title
if let iconName = action.icon {
configuration.image = UIImage(systemName: iconName)
configuration.imagePlacement = .trailing
configuration.imagePadding = DesignTokens.Spacing.xs
}
configuration.cornerStyle = .capsule
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = UIFont.preferredFont(forTextStyle: .body, weight: .semibold)
return outgoing
}
let button = UIButton(
configuration: configuration,
primaryAction: UIAction { _ in
action.action()
}
)
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: Constants.height).isActive = true
return button
}
}
================================================
FILE: Sources/PageViewController/PageHeader/PageCellItem+pageHeader.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func pageHeader(
headline: String,
subheadline: String
) -> PageCellItem {
PageCellItem(
id: headline + subheadline,
cellType: PageHeaderCell.self,
sizing: .fullWidth(estimatedHeight: 100),
viewModel: PageHeaderCellViewModel(
headline: headline,
subheadline: subheadline
)
)
}
}
================================================
FILE: Sources/PageViewController/PageHeader/PageHeaderCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class PageHeaderCell: UICollectionViewCell, PageCell {
private struct Constants {
struct Subheadline {
static let maxWidth = CGFloat(300)
}
}
static let reuseIdentifier = "PageHeaderCell"
private let headlineLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.preferredFont(forTextStyle: .largeTitle, weight: .bold)
label.textAlignment = .center
label.numberOfLines = 0
return label
}()
private let subheadlineLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.preferredFont(forTextStyle: .subheadline, weight: .medium)
label.textAlignment = .center
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("\(#function) has not been implemented")
}
private func setUpViews() {
contentView.addSubview(headlineLabel)
contentView.addSubview(subheadlineLabel)
NSLayoutConstraint.activate([
headlineLabel.topAnchor.constraint(equalTo: contentView.topAnchor),
headlineLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
headlineLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
subheadlineLabel.topAnchor.constraint(
equalTo: headlineLabel.bottomAnchor,
constant: DesignTokens.Spacing.xs
),
subheadlineLabel.leadingAnchor.constraint(
greaterThanOrEqualTo: contentView.layoutMarginsGuide.leadingAnchor
),
subheadlineLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor).withPriority(.defaultHigh),
subheadlineLabel.widthAnchor.constraint(lessThanOrEqualToConstant: Constants.Subheadline.maxWidth),
subheadlineLabel.trailingAnchor.constraint(
lessThanOrEqualTo: contentView.layoutMarginsGuide.trailingAnchor
),
subheadlineLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
])
}
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attributes = super.preferredLayoutAttributesFitting(layoutAttributes)
let size = contentView.systemLayoutSizeFitting(
CGSize(width: attributes.frame.width, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
attributes.frame.size.height = size.height
return attributes
}
func configure(viewModel: PageHeaderCellViewModel) {
headlineLabel.text = viewModel.headline
subheadlineLabel.text = viewModel.subheadline
}
}
================================================
FILE: Sources/PageViewController/PageHeader/PageHeaderViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
final class PageHeaderCellViewModel: PageCellViewModel {
let headline: String
let subheadline: String
init(
headline: String,
subheadline: String
) {
self.headline = headline
self.subheadline = subheadline
}
func handle(action: PageCellAction) {
/* no-op */
}
}
================================================
FILE: Sources/PageViewController/PageNavigationItem.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
enum PageNavigationItem: Sendable {
case text(
title: String,
onAction: @Sendable () -> Void
)
case symbol(
systemImageName: String,
onAction: @Sendable () -> Void
)
}
================================================
FILE: Sources/PageViewController/PageNavigationSymbolBarButtonItemFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
enum PrimaryBarButtonAction: Sendable {
case action(UIAction)
case menu(UIMenu)
}
@MainActor
protocol SymbolBarButtonItemFactory: Sendable {
func make(
symbolName: String,
primaryAction: PrimaryBarButtonAction
) -> UIBarButtonItem
}
struct IOS18SymbolBarButtonItemFactory: SymbolBarButtonItemFactory {
func make(
symbolName: String,
primaryAction: PrimaryBarButtonAction
) -> UIBarButtonItem {
var configuration = UIButton.Configuration.filled()
configuration.image = UIImage(systemName: symbolName)?
.withTintColor(.systemBackground, renderingMode: .alwaysOriginal)
configuration.baseBackgroundColor = .label
configuration.baseForegroundColor = .systemBackground
configuration.cornerStyle = .capsule
configuration.buttonSize = .medium
switch primaryAction {
case let .action(action):
return UIBarButtonItem(
customView: UIButton(
configuration: configuration,
primaryAction: action
)
)
case let .menu(menu):
let button = UIButton(configuration: configuration)
button.menu = menu
button.showsMenuAsPrimaryAction = true
return UIBarButtonItem(customView: button)
}
}
}
@available(iOS 26, *)
struct IOS26SymbolBarButtonItemFactory: SymbolBarButtonItemFactory {
func make(
symbolName: String,
primaryAction: PrimaryBarButtonAction
) -> UIBarButtonItem {
let image = UIImage(systemName: symbolName)
return switch primaryAction {
case let .action(action):
UIBarButtonItem(image: image, primaryAction: action)
case let .menu(menu):
UIBarButtonItem(image: image, menu: menu)
}
}
}
================================================
FILE: Sources/PageViewController/PageNavigationTextBarButtonItemFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol TextBarButtonItemFactory: Sendable {
func make(
title: String,
primaryAction: UIAction
) -> UIBarButtonItem
}
struct IOS18TextBarButtonItemFactory: TextBarButtonItemFactory {
func make(
title: String,
primaryAction: UIAction
) -> UIBarButtonItem {
var configuration = UIButton.Configuration.filled()
configuration.title = title
configuration.baseBackgroundColor = .label
configuration.baseForegroundColor = .systemBackground
configuration.cornerStyle = .capsule
configuration.buttonSize = .medium
return UIBarButtonItem(
customView: UIButton(
configuration: configuration,
primaryAction: primaryAction
)
)
}
}
@available(iOS 26, *)
struct IOS26TextBarButtonItemFactory: TextBarButtonItemFactory {
func make(
title: String,
primaryAction: UIAction
) -> UIBarButtonItem {
UIBarButtonItem(
title: title,
primaryAction: primaryAction
)
}
}
================================================
FILE: Sources/PageViewController/PageViewController.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class PageViewController: UIViewController {
private lazy var collectionViewLayout: UICollectionViewCompositionalLayout = {
let configuration = UICollectionViewCompositionalLayoutConfiguration()
return UICollectionViewCompositionalLayout(
sectionProvider: { [weak self] _, environment in
guard let self else {
return self?.makeDefaultLayoutSection()
}
return makeLayoutSection(
items: self.dataSource.snapshot().itemIdentifiers,
contentWidth: environment.container.effectiveContentSize.width
)
},
configuration: configuration
)
}()
private lazy var dataSource: UICollectionViewDiffableDataSource = {
UICollectionViewDiffableDataSource(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
self?.registerIfNeeded(item.cellType)
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: item.cellType.reuseIdentifier,
for: indexPath
)
item.configure(cell)
return cell
}
}()
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .systemGroupedBackground
collectionView.preservesSuperviewLayoutMargins = true
collectionView.contentInset.bottom = DesignTokens.Spacing.md
collectionView.delegate = self
return collectionView
}()
private lazy var callToActionView: PageCallToActionView = {
let view = PageCallToActionView(actions: viewModel.actions)
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private lazy var withCallToActionViewConstraints = [
collectionView.bottomAnchor.constraint(equalTo: callToActionView.topAnchor),
callToActionView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
callToActionView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
callToActionView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor),
]
private lazy var withoutCallToActionViewConstraints = [
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
]
private var registeredReuseIdentifiers = Set()
private var leftNavigationItemsTask: Task?
private var rightNavigationItemsTask: Task?
private var itemsTask: Task?
private let viewModel: PageViewModel
private let textBarButtonItemFactory: TextBarButtonItemFactory
private let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
init(
viewModel: PageViewModel,
textBarButtonItemFactory: TextBarButtonItemFactory,
symbolBarButtonItemFactory: SymbolBarButtonItemFactory
) {
self.viewModel = viewModel
self.textBarButtonItemFactory = textBarButtonItemFactory
self.symbolBarButtonItemFactory = symbolBarButtonItemFactory
super.init(nibName: nil, bundle: nil)
itemsTask = Task { [weak self] in
for await items in viewModel.items {
self?.handleItems(items)
}
}
leftNavigationItemsTask = Task { [weak self] in
for await navigationItems in viewModel.leftNavigationItems {
self?.handleLeftNavigationItems(navigationItems: navigationItems)
}
}
rightNavigationItemsTask = Task { [weak self] in
for await navigationItems in viewModel.rightNavigationItems {
self?.handleRightNavigationItems(navigationItems: navigationItems)
}
}
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("\(#function) has not been implemented")
}
deinit {
itemsTask?.cancel()
leftNavigationItemsTask?.cancel()
rightNavigationItemsTask?.cancel()
}
private func setUpViews() {
navigationItem.title = viewModel.title
view.backgroundColor = .systemGroupedBackground
view.directionalLayoutMargins.bottom = DesignTokens.Spacing.md
navigationItem.largeTitleDisplayMode = .never
view.addSubview(collectionView)
var constraints = [
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
]
if viewModel.actions.isEmpty {
constraints.append(contentsOf: withoutCallToActionViewConstraints)
} else {
view.addSubview(callToActionView)
constraints.append(contentsOf: withCallToActionViewConstraints)
}
NSLayoutConstraint.activate(constraints)
}
override func viewDidLoad() {
super.viewDidLoad()
viewModel.didLoad()
}
private func registerIfNeeded(_ cellType: any PageCell.Type) {
let identifier = cellType.reuseIdentifier
guard !registeredReuseIdentifiers.contains(identifier) else {
return
}
collectionView.register(cellType, forCellWithReuseIdentifier: identifier)
registeredReuseIdentifiers.insert(identifier)
}
private func handleItems(_ items: [PageCellItem]) {
var snapshot = NSDiffableDataSourceSnapshot()
snapshot.appendSections([0])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: true)
collectionViewLayout.invalidateLayout()
}
private func handleRightNavigationItems(navigationItems: [PageNavigationItem]) {
if let firstNavigationItem = navigationItems.first, navigationItems.count == 1 {
navigationItem.setRightBarButton(
makeNavigationItem(navigationItem: firstNavigationItem),
animated: true
)
} else {
navigationItem.setRightBarButtonItems(
navigationItems.map(makeNavigationItem),
animated: true
)
}
}
private func handleLeftNavigationItems(navigationItems: [PageNavigationItem]) {
if let firstNavigationItem = navigationItems.first, navigationItems.count == 1 {
navigationItem.setLeftBarButton(
makeNavigationItem(navigationItem: firstNavigationItem),
animated: true
)
} else {
navigationItem.setLeftBarButtonItems(
navigationItems.map(makeNavigationItem),
animated: true
)
}
}
private func makeNavigationItem(navigationItem: PageNavigationItem) -> UIBarButtonItem {
switch navigationItem {
case let .text(title, onAction):
textBarButtonItemFactory.make(
title: title,
primaryAction: UIAction { _ in
onAction()
}
)
case let .symbol(symbolName, onAction):
symbolBarButtonItemFactory.make(
symbolName: symbolName,
primaryAction: .action(
UIAction { _ in
onAction()
}
)
)
}
}
private func makeDefaultLayoutSection() -> NSCollectionLayoutSection {
let (group, _) = makeFullWidthRowLayoutGroup(estimatedHeight: 120)
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = .layoutMargins
return section
}
private func makeLayoutSection(
items: [PageCellItem],
contentWidth: CGFloat
) -> NSCollectionLayoutSection {
let rowGroups = makeRowLayoutGroups(
items: items,
contentWidth: contentWidth
)
guard !rowGroups.isEmpty else {
return makeDefaultLayoutSection()
}
let group = NSCollectionLayoutGroup.vertical(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(1000)
),
subitems: rowGroups
)
group.interItemSpacing = .fixed(items.first?.sizing.rowSpacing ?? DesignTokens.Spacing.md)
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = .layoutMargins
return section
}
private func makeRowLayoutGroups(
items: [PageCellItem],
contentWidth: CGFloat
) -> [NSCollectionLayoutGroup] {
var groups: [NSCollectionLayoutGroup] = []
var i = 0
while i < items.count {
let (group, consumed) = makeRowLayoutGroup(
sizing: items[i].sizing,
contentWidth: contentWidth
)
groups.append(group)
i += min(consumed, items.count - i)
}
return groups
}
private func makeRowLayoutGroup(
sizing: PageCellSizingStrategy,
contentWidth: CGFloat
) -> (NSCollectionLayoutGroup, Int) {
switch sizing {
case let .fullWidth(estimatedHeight, _):
makeFullWidthRowLayoutGroup(
estimatedHeight: estimatedHeight
)
case let .equalSplit(perRow, height, columnSpacing, _):
makeEqualSplitRowLayoutGroup(
perRow: perRow,
height: height,
columnSpacing: columnSpacing
)
case let .adaptiveGrid(minColumns, maxColumns, minItemWidth, maxItemWidth, columnSpacing, _, aspectRatio):
makeAdaptiveGridRowLayoutGroup(
minColumns: minColumns,
maxColumns: maxColumns,
minItemWidth: minItemWidth,
maxItemWidth: maxItemWidth,
columnSpacing: columnSpacing,
aspectRatio: aspectRatio,
contentWidth: contentWidth
)
}
}
private func makeFullWidthRowLayoutGroup(
estimatedHeight: CGFloat
) -> (NSCollectionLayoutGroup, Int) {
let item = NSCollectionLayoutItem(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(estimatedHeight)
)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(estimatedHeight)
),
subitems: [item]
)
return (group, 1)
}
private func makeEqualSplitRowLayoutGroup(
perRow: Int,
height: CGFloat,
columnSpacing: CGFloat
) -> (NSCollectionLayoutGroup, Int) {
let item = NSCollectionLayoutItem(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0 / CGFloat(perRow)),
heightDimension: .absolute(height)
)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(height)
),
subitem: item,
count: perRow
)
group.interItemSpacing = .fixed(columnSpacing)
return (group, perRow)
}
private func makeAdaptiveGridRowLayoutGroup(
minColumns: Int,
maxColumns: Int,
minItemWidth: CGFloat,
maxItemWidth: CGFloat,
columnSpacing: CGFloat,
aspectRatio: CGSize,
contentWidth: CGFloat
) -> (NSCollectionLayoutGroup, Int) {
let columnsNeeded = Int(ceil((contentWidth + columnSpacing) / (maxItemWidth + columnSpacing)))
let columnsAllowed = Int((contentWidth + columnSpacing) / (minItemWidth + columnSpacing))
let columns = max(minColumns, min(maxColumns, min(columnsAllowed, max(columnsNeeded, minColumns))))
let itemWidth = (contentWidth - columnSpacing * CGFloat(columns - 1)) / CGFloat(columns)
let itemHeight = itemWidth * aspectRatio.height / aspectRatio.width
let item = NSCollectionLayoutItem(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0 / CGFloat(columns)),
heightDimension: .absolute(itemHeight)
)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(itemHeight)
),
subitem: item,
count: columns
)
group.interItemSpacing = .fixed(columnSpacing)
return (group, columns)
}
}
extension PageViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
dataSource.itemIdentifier(for: indexPath)?.handleAction(.tap)
}
func collectionView(
_ collectionView: UICollectionView,
contextMenuConfigurationForItemAt indexPath: IndexPath,
point: CGPoint
) -> UIContextMenuConfiguration? {
guard let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
let cellPoint = collectionView.convert(point, to: cell)
return dataSource.itemIdentifier(for: indexPath)?.contextMenuConfiguration(cellPoint, cell)
}
}
================================================
FILE: Sources/PageViewController/PageViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol PageViewModel: AnyObject {
var title: String? { get }
var leftNavigationItems: AsyncStream<[PageNavigationItem]> { get }
var rightNavigationItems: AsyncStream<[PageNavigationItem]> { get }
var items: AsyncStream<[PageCellItem]> { get }
var actions: [PageCallToActionView.ActionConfiguration] { get }
func didLoad()
}
extension PageViewModel {
var title: String? {
nil
}
var actions: [PageCallToActionView.ActionConfiguration] {
[]
}
var leftNavigationItems: AsyncStream<[PageNavigationItem]> {
AsyncStream { continuation in
continuation.finish()
}
}
var rightNavigationItems: AsyncStream<[PageNavigationItem]> {
AsyncStream { continuation in
continuation.finish()
}
}
func didLoad() {
/* no-op */
}
}
================================================
FILE: Sources/RevealFile/RevealFileCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class RevealFileCoordinator: Coordinator {
var onEnd: (() -> Void)?
private let jotFileInfo: JotFile.Info
private let applicationService: ApplicationServiceProtocol
init(
jotFileInfo: JotFile.Info,
applicationService: ApplicationServiceProtocol
) {
self.jotFileInfo = jotFileInfo
self.applicationService = applicationService
}
func start() {
defer {
onEnd?()
}
#if targetEnvironment(macCatalyst)
guard
let nsWorkspaceClass = NSClassFromString("NSWorkspace"),
let workspace = nsWorkspaceClass.value(forKeyPath: "sharedWorkspace") as? NSObject
else {
return
}
workspace.perform(
NSSelectorFromString("selectFile:inFileViewerRootedAtPath:"),
with: jotFileInfo.url.path,
with: ""
)
#else
let revealFileURL = RevealFileURL(jotFileInfo: jotFileInfo)
applicationService.open(url: revealFileURL.toURL())
#endif
}
}
================================================
FILE: Sources/RevealFile/RevealFileCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol RevealFileCoordinatorFactoryProtocol: Sendable {
func make(
jotFileInfo: JotFile.Info,
navigation: Navigation
) -> Coordinator
}
struct RevealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol {
let applicationService: ApplicationServiceProtocol
func make(
jotFileInfo: JotFile.Info,
navigation: Navigation
) -> Coordinator {
RevealFileCoordinator(
jotFileInfo: jotFileInfo,
applicationService: applicationService
)
}
}
================================================
FILE: Sources/RevealFile/RevealFileURL.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct RevealFileURL: URLConvertible {
let scheme: String? = "shareddocuments"
let host: String? = ""
let path: String
init(jotFileInfo: JotFile.Info) {
path = jotFileInfo.url.path
}
}
================================================
FILE: Sources/RootCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class RootCoordinator: NavigationCoordinator {
private lazy var jotsCoordinator = jotsCoordinatorFactory.make(navigation: navigation)
private let navigation: Navigation
private let jotsCoordinatorFactory: JotsCoordinatorFactoryProtocol
init(
navigation: Navigation,
jotsCoordinatorFactory: JotsCoordinatorFactoryProtocol
) {
self.navigation = navigation
self.jotsCoordinatorFactory = jotsCoordinatorFactory
}
func shouldHandle(url: URL) -> Bool {
true
}
func handle(url: URL) -> [UIViewController] {
var viewControllers = [UIViewController]()
viewControllers.append(contentsOf: jotsCoordinator.handle(url: url))
return viewControllers
}
}
================================================
FILE: Sources/RootCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@MainActor
protocol RootCoordinatorFactoryProtocol {
func make(navigation: Navigation) -> NavigationCoordinator
}
struct RootCoordinatorFactory: RootCoordinatorFactoryProtocol {
let jotsCoordinatorFactory: JotsCoordinatorFactoryProtocol
func make(navigation: Navigation) -> NavigationCoordinator {
RootCoordinator(
navigation: navigation,
jotsCoordinatorFactory: jotsCoordinatorFactory
)
}
}
================================================
FILE: Sources/SceneCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
final class SceneCoordinator {
enum Constants {
static let activityType = "com.antonlorani.jottre.openJot"
static let urlKey = "url"
static let fileBookmarkKey = "fileBookmark"
}
private var lastActiveURL: URL?
private var navigationController: UINavigationController?
private var retainedRootCoordinator: NavigationCoordinator?
private var userInterfaceStyleTask: Task?
#if targetEnvironment(macCatalyst)
private var securityScopedURL: URL?
#endif
private let navigation: Navigation
private let defaultsService: DefaultsServiceProtocol
private let applicationService: ApplicationServiceProtocol
private let localFileService: FileServiceProtocol
private let ubiquitousFileService: FileServiceProtocol
private let logger: LoggerProtocol
private let rootCoordinatorFactory: RootCoordinatorFactoryProtocol
private let editJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol
private let onUpdateUserInterfaceStyle: @Sendable (_ userInterfaceStyle: UIUserInterfaceStyle) -> Void
private let requestSceneSessionActivationProvider: @Sendable (_ url: URL) -> Void
init(
navigation: Navigation,
defaultsService: DefaultsServiceProtocol,
applicationService: ApplicationServiceProtocol,
localFileService: FileServiceProtocol,
ubiquitousFileService: FileServiceProtocol,
logger: LoggerProtocol,
rootCoordinatorFactory: RootCoordinatorFactoryProtocol,
editJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol,
onUpdateUserInterfaceStyle: @Sendable @escaping (_ userInterfaceStyle: UIUserInterfaceStyle) -> Void,
requestSceneSessionActivationProvider: @Sendable @escaping (_ url: URL) -> Void
) {
self.navigation = navigation
self.defaultsService = defaultsService
self.applicationService = applicationService
self.localFileService = localFileService
self.ubiquitousFileService = ubiquitousFileService
self.logger = logger
self.rootCoordinatorFactory = rootCoordinatorFactory
self.editJotCoordinatorFactory = editJotCoordinatorFactory
self.onUpdateUserInterfaceStyle = onUpdateUserInterfaceStyle
self.requestSceneSessionActivationProvider = requestSceneSessionActivationProvider
}
func handle(url: URL) -> [UIViewController] {
lastActiveURL = url
return retainedRootCoordinator?.handle(url: url) ?? []
}
func handle(
session: UISceneSession,
connectionOptions: UIScene.ConnectionOptions
) -> [UIViewController] {
let url: URL
let coordinator: NavigationCoordinator
if let (activityURL, isRestored) = getActivityURL(session: session, connectionOptions: connectionOptions) {
lazy var editJotCoordinator = editJotCoordinatorFactory.make(navigation: navigation)
lazy var rootCoordinator = rootCoordinatorFactory.make(navigation: navigation)
let preferredCoordinator: NavigationCoordinator
if isEditJotURL(url: activityURL) {
preferredCoordinator = editJotCoordinator
url = activityURL
} else if let editJotURL = makeEditJotURL(fileURL: activityURL) {
preferredCoordinator = editJotCoordinator
url = editJotURL
} else {
preferredCoordinator = rootCoordinator
url = activityURL
}
if isRestored {
#if targetEnvironment(macCatalyst)
coordinator = preferredCoordinator
#else
// On iPadOS its more tedious for users to create a new fresh window. Therefore we prefer
// restoring a scene that allows navigating back to a jots overview (When the activityURL
// opens a nested hierarchy).
coordinator = rootCoordinator
#endif
} else {
if applicationService.supportsMultipleScenes() {
coordinator = preferredCoordinator
} else {
coordinator = rootCoordinator
}
}
} else {
url = JotsPageURL().toURL()
coordinator = rootCoordinatorFactory.make(navigation: navigation)
}
lastActiveURL = url
retainedRootCoordinator = coordinator
userInterfaceStyleTask?.cancel()
userInterfaceStyleTask = Task {
for await userInterfaceStyle in defaultsService.getValueStream(.userInterfaceStyle) {
let userInterfaceStyle =
userInterfaceStyle
.flatMap(UIUserInterfaceStyle.init(rawValue:)) ?? .unspecified
onUpdateUserInterfaceStyle(userInterfaceStyle)
}
}
Task { [weak self] in
guard let self else {
return
}
do {
try await localFileService.initializeDocumentsDirectory()
try await ubiquitousFileService.initializeDocumentsDirectory()
logger.info("Initialized documents directories.")
} catch {
logger.error("Failed to initialize documents directory: \(error)")
}
}
return coordinator.handle(url: url)
}
func handleURLContexts(
urlContexts: Set
) {
guard let incomingURL = urlContexts.first?.url else {
return
}
guard incomingURL.pathExtension == JotFile.Info.fileExtension else {
navigation.open(url: incomingURL)
return
}
openScene(url: incomingURL)
}
func openScene(url: URL) {
if applicationService.supportsMultipleScenes() {
requestSceneSessionActivationProvider(url)
} else {
navigation.open(url: url)
}
}
func handlePop() {
lastActiveURL = nil
}
func makeStateRestorationActivity() -> NSUserActivity? {
guard
let lastActiveURL,
applicationService.supportsMultipleScenes()
else {
return nil
}
let activity = NSUserActivity(activityType: Constants.activityType)
activity.userInfo = makeUserInfo(lastActiveURL: lastActiveURL)
return activity
}
private func makeUserInfo(lastActiveURL: URL) -> [AnyHashable: Any] {
var userInfo: [AnyHashable: Any] = [Constants.urlKey: lastActiveURL.absoluteString]
guard let fileURL = EditJotURL(url: lastActiveURL)?.fileURL else {
return userInfo
}
#if targetEnvironment(macCatalyst)
let bookmarkOptions = URL.BookmarkCreationOptions.withSecurityScope
#else
let bookmarkOptions = URL.BookmarkCreationOptions()
#endif
if let bookmark = try? fileURL.bookmarkData(options: bookmarkOptions) {
userInfo[Constants.fileBookmarkKey] = bookmark
}
return userInfo
}
private func getActivityURL(
session: UISceneSession,
connectionOptions: UIScene.ConnectionOptions
) -> (url: URL, isRestored: Bool)? {
if let activity = session.stateRestorationActivity,
let url = getURL(activity: activity)
{
return (url: url, isRestored: true)
}
if let activity = connectionOptions.userActivities.first(where: { $0.activityType == Constants.activityType }),
let url = getURL(activity: activity)
{
return (url: url, isRestored: false)
}
if let firstURLContextURL = connectionOptions.urlContexts.first?.url {
return (url: firstURLContextURL, isRestored: false)
}
return nil
}
private func getURL(activity: NSUserActivity) -> URL? {
guard
activity.activityType == Constants.activityType,
let urlString = activity.userInfo?[Constants.urlKey] as? String,
let url = URL(string: urlString)
else {
return nil
}
guard
let bookmark = activity.userInfo?[Constants.fileBookmarkKey] as? Data
else {
return url
}
var isStale = false
#if targetEnvironment(macCatalyst)
let bookmarkOptions = URL.BookmarkResolutionOptions.withSecurityScope
#else
let bookmarkOptions = URL.BookmarkResolutionOptions()
#endif
guard
let resolved = try? URL(
resolvingBookmarkData: bookmark,
options: bookmarkOptions,
bookmarkDataIsStale: &isStale
),
EditJotURL(url: url) != nil,
let rebuilt = makeEditJotURL(fileURL: resolved)
else {
return url
}
#if targetEnvironment(macCatalyst)
if resolved.startAccessingSecurityScopedResource() {
securityScopedURL?.stopAccessingSecurityScopedResource()
securityScopedURL = resolved
}
#endif
return rebuilt
}
private func isEditJotURL(url: URL) -> Bool {
EditJotURL(url: url) != nil
}
private func makeEditJotURL(fileURL: URL) -> URL? {
guard
let jotFileInfo = JotFile.Info(
url: fileURL,
modificationDate: nil,
ubiquitousInfo: nil
)
else {
return nil
}
return EditJotURL(jotFileInfo: jotFileInfo).toURL()
}
deinit {
#if targetEnvironment(macCatalyst)
securityScopedURL?.stopAccessingSecurityScopedResource()
#endif
userInterfaceStyleTask?.cancel()
}
}
================================================
FILE: Sources/SceneDelegate.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private static let defaultsService = DefaultsService(userDefaults: .standard)
#if targetEnvironment(macCatalyst)
private lazy var appKitPluginService = MacCatalystAppKitPluginService(bundle: .main)
#endif
var window: UIWindow?
private var sceneCoordinator: SceneCoordinator?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else {
return
}
#if targetEnvironment(macCatalyst)
windowScene.title = L10n.App.title
#endif
let fileManager = FileManager.default
let localFileService = LocalFileService(
fileManager: fileManager
)
let ubiquitousFileService = UbiquitousFileService(
fileManager: fileManager,
localFileService: localFileService
)
let fileConflictService = FileConflictService(
fileManager: fileManager
)
let bundleService = BundleService(
bundle: .main
)
let jotFileService = JotFileService(
localFileService: localFileService,
ubiquitousFileService: ubiquitousFileService
)
let jotFileConflictService = JotFileConflictService(
fileConflictService: fileConflictService
)
let jotFilePreviewImageService = CachedJotFilePreviewImageService(
localFileService: localFileService,
jotFilePreviewImageService: JotFilePreviewImageService(
jotFileService: jotFileService
)
)
let applicationService = ApplicationService(
application: .shared
)
let deviceService = DeviceService(
device: .current
)
let textBarButtonItemFactory: TextBarButtonItemFactory
let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
if #available(iOS 26, *) {
textBarButtonItemFactory = IOS26TextBarButtonItemFactory()
symbolBarButtonItemFactory = IOS26SymbolBarButtonItemFactory()
} else {
textBarButtonItemFactory = IOS18TextBarButtonItemFactory()
symbolBarButtonItemFactory = IOS18SymbolBarButtonItemFactory()
}
let menuConfigurationFactory = JotMenuConfigurationFactory()
let deleteJotCoordinatorFactory = DeleteJotCoordinatorFactory(
repository: DeleteJotRepository(
jotFileService: jotFileService
)
)
let shareJotCoordinatorFactory = ShareJotCoordinatorFactory(
repository: ShareJotRepository(
jotFileService: jotFileService,
fileService: localFileService
)
)
let revealFileCoordinatorFactory = RevealFileCoordinatorFactory(
applicationService: applicationService
)
let editJotRepository = EditJotRepository(
ubiquitousFileService: ubiquitousFileService,
jotFileService: jotFileService,
jotFileConflictService: jotFileConflictService
)
let editJotCoordinatorFactory = EditJotCoordinatorFactory(
repository: editJotRepository,
editJotViewControllerFactory: EditJotViewControllerFactory(
repository: editJotRepository,
menuConfigurationFactory: menuConfigurationFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory,
logger: OSLogLogger(category: "EditJotViewModel")
),
jotConflictCoordinatorFactory: JotConflictCoordinatorFactory(
jotConflictViewControllerFactory: JotConflictViewControllerFactory(
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
),
repository: JotConflictRepository(
jotFileConflictService: jotFileConflictService,
jotFilePreviewImageService: jotFilePreviewImageService,
logger: OSLogLogger(category: "JotConflictRepository")
)
),
renameJotCoordinatorFactory: RenameJotCoordinatorFactory(
repository: RenameJotRepository(
jotFileService: jotFileService
)
),
deleteJotCoordinatorFactory: deleteJotCoordinatorFactory,
shareJotCoordinatorFactory: shareJotCoordinatorFactory,
revealFileCoordinatorFactory: revealFileCoordinatorFactory
)
let jotsCoordinatorFactory: JotsCoordinatorFactoryProtocol = JotsCoordinatorFactory(
jotsViewControllerFactory: JotsViewControllerFactory(
repository: JotsRepository(
ubiquitousFileService: ubiquitousFileService,
applicationService: applicationService,
deviceService: deviceService,
jotFileService: jotFileService,
jotFilePreviewImageService: jotFilePreviewImageService
),
menuConfigurationFactory: menuConfigurationFactory,
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory,
logger: OSLogLogger(category: "JotsViewModel")
),
settingsCoordinatorFactory: SettingsCoordinatorFactory(
settingsViewControllerFactory: SettingsViewControllerFactory(
repository: SettingsRepository(
ubiquitousFileService: ubiquitousFileService,
bundleService: bundleService,
defaultsService: Self.defaultsService
),
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
)
),
enableCloudCoordinatorFactory: EnableCloudCoordinatorFactory(
enableCloudViewControllerFactory: EnableCloudViewControllerFactory(
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
)
),
editJotCoordinatorFactory: editJotCoordinatorFactory,
cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactory(
repository: CloudMigrationRepository(
ubiquitousFileService: ubiquitousFileService,
jotFileService: jotFileService,
jotFilePreviewImageService: jotFilePreviewImageService,
defaultsService: Self.defaultsService
),
cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactory(
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
),
logger: OSLogLogger(category: "CloudMigrationViewModel")
),
createJotCoordinatorFactory: CreateJotCoordinatorFactory(
repository: CreateJotRepository(
localFileService: localFileService,
ubiquitousFileService: ubiquitousFileService,
jotFileService: jotFileService
)
),
deleteJotCoordinatorFactory: deleteJotCoordinatorFactory,
renameJotCoordinatorFactory: RenameJotCoordinatorFactory(
repository: RenameJotRepository(
jotFileService: jotFileService
)
),
shareJotCoordinatorFactory: shareJotCoordinatorFactory,
revealFileCoordinatorFactory: revealFileCoordinatorFactory
)
let rootCoordinatorFactory = RootCoordinatorFactory(
jotsCoordinatorFactory: jotsCoordinatorFactory
)
let navigationController = makeNavigationController()
let navigation = Navigation(
openURLProvider: { [weak self, weak navigationController] url in
Task { @MainActor in
guard let viewControllers = self?.sceneCoordinator?.handle(url: url) else {
return
}
navigationController?.setViewControllers(viewControllers, animated: true)
}
},
openExternalURLProvider: { url in
Task { @MainActor in
guard applicationService.canOpen(url: url) else {
return
}
applicationService.open(url: url)
}
},
openSceneProvider: { [weak self] url in
Task { @MainActor in
self?.sceneCoordinator?.openScene(url: url)
}
},
presentViewControllerProvider: { [weak navigationController] viewController, animated in
Task { @MainActor in
navigationController?.present(viewController, animated: animated)
}
},
dismissViewControllerProvider: { [weak navigationController] animated, completion in
Task { @MainActor in
navigationController?.dismiss(animated: animated, completion: completion)
}
},
popViewControllerProvider: { [weak self, weak navigationController] animated in
Task { @MainActor in
navigationController?.popViewController(animated: animated)
self?.sceneCoordinator?.handlePop()
}
},
getViewControllersProvider: { [weak navigationController] in
navigationController?.viewControllers ?? []
}
)
let window = UIWindow(windowScene: windowScene)
window.rootViewController = navigationController
self.window = window
let sceneCoordinator = SceneCoordinator(
navigation: navigation,
defaultsService: Self.defaultsService,
applicationService: applicationService,
localFileService: localFileService,
ubiquitousFileService: ubiquitousFileService,
logger: OSLogLogger(category: "SceneCoordinator"),
rootCoordinatorFactory: rootCoordinatorFactory,
editJotCoordinatorFactory: editJotCoordinatorFactory,
onUpdateUserInterfaceStyle: { [weak window] userInterfaceStyle in
Task { @MainActor in
window?.overrideUserInterfaceStyle = userInterfaceStyle
}
},
requestSceneSessionActivationProvider: { url in
Task { @MainActor in
let activity = NSUserActivity(
activityType: SceneCoordinator.Constants.activityType
)
activity.userInfo = [SceneCoordinator.Constants.urlKey: url.absoluteString]
UIApplication.shared.requestSceneSessionActivation(
nil,
userActivity: activity,
options: nil,
errorHandler: nil
)
}
}
)
self.sceneCoordinator = sceneCoordinator
navigationController.viewControllers = sceneCoordinator.handle(
session: session,
connectionOptions: connectionOptions
)
window.makeKeyAndVisible()
}
#if targetEnvironment(macCatalyst)
func sceneDidDisconnect(_ scene: UIScene) {
guard
UIApplication.shared.connectedScenes.isEmpty,
let appKitPluginService
else {
return
}
appKitPluginService.terminate()
}
#endif
func scene(
_ scene: UIScene,
openURLContexts URLContexts: Set
) {
sceneCoordinator?.handleURLContexts(urlContexts: URLContexts)
}
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
sceneCoordinator?.makeStateRestorationActivity()
}
private func makeNavigationController() -> UINavigationController {
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
let navigationController = UINavigationController()
navigationController.navigationBar.prefersLargeTitles = true
navigationController.navigationBar.standardAppearance = appearance
navigationController.navigationBar.scrollEdgeAppearance = appearance
navigationController.navigationBar.tintColor = .label
return navigationController
}
}
================================================
FILE: Sources/SettingsPage/DefaultsKey+userInterfaceStyle.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension DefaultsKey {
static var userInterfaceStyle: DefaultsKey {
#function
}
}
================================================
FILE: Sources/SettingsPage/DropDownCell/PageCellItem+settingsDropdown.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func settingsDropdown(
settingsDropdown: SettingsDropdownBusinessModel,
onAction: @Sendable @escaping (SettingsDropdownBusinessModel.Option) -> Void
) -> PageCellItem {
PageCellItem(
id: settingsDropdown,
cellType: SettingsDropdownCell.self,
sizing: .fullWidth(estimatedHeight: 56),
viewModel: SettingsDropdownCellViewModel(
settingsDropdown: settingsDropdown,
onAction: onAction
)
)
}
}
================================================
FILE: Sources/SettingsPage/DropDownCell/SettingsDropdownBusinessModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct SettingsDropdownBusinessModel: Sendable, Hashable {
struct Option: Sendable, Hashable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.hashValue == rhs.hashValue
}
let label: String
let value: any Hashable & Sendable
func hash(into hasher: inout Hasher) {
hasher.combine(label)
hasher.combine(value)
}
}
let name: String
let current: Option
let options: [Option]
}
================================================
FILE: Sources/SettingsPage/DropDownCell/SettingsDropdownCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class SettingsDropdownCell: SettingsCell, PageCell {
override init(frame: CGRect) {
super.init(frame: frame, accessoryView: UIButton(type: .system))
accessoryView.showsMenuAsPrimaryAction = true
accessoryView.titleLabel?.font = .preferredFont(forTextStyle: .body, weight: .semibold)
accessoryView.setTitleColor(.label, for: .normal)
}
func configure(viewModel: SettingsDropdownCellViewModel) {
nameLabel.text = viewModel.name
accessoryView.setTitle(viewModel.current.label, for: .normal)
accessoryView.menu = UIMenu(
children: viewModel.options.map { option in
UIAction(
title: option.label,
state: AnyHashable(option.value.hashValue) == AnyHashable(viewModel.current.value) ? .on : .off
) { _ in
viewModel.onAction(option)
}
}
)
}
}
================================================
FILE: Sources/SettingsPage/DropDownCell/SettingsDropdownCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
final class SettingsDropdownCellViewModel: PageCellViewModel {
let name: String
let current: SettingsDropdownBusinessModel.Option
let options: [SettingsDropdownBusinessModel.Option]
let onAction: @Sendable (SettingsDropdownBusinessModel.Option) -> Void
init(
settingsDropdown: SettingsDropdownBusinessModel,
onAction: @Sendable @escaping (SettingsDropdownBusinessModel.Option) -> Void
) {
self.name = settingsDropdown.name
self.current = settingsDropdown.current
self.options = settingsDropdown.options
self.onAction = onAction
}
func handle(action: PageCellAction) {
/* no-op */
}
}
================================================
FILE: Sources/SettingsPage/EnableICloudSupportURL.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
struct EnableICloudSupportURL: URLConvertible {
let scheme: String? = "https"
let host: String? = "support.apple.com"
let path: String
init(locale: Locale = .current) {
if let language = locale.languageCode,
let region = locale.regionCode
{
path = "/\(language)-\(region.lowercased())/guide/icloud/mmfc0f1e2a/icloud"
} else {
path = "/guide/icloud/mmfc0f1e2a/icloud"
}
}
}
================================================
FILE: Sources/SettingsPage/ExternalLinkCell/PageCellItem+settingsExternalLink.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func settingsExternalLink(
settingsExternalLink: SettingsExternalLinkBusinessModel,
onAction: @Sendable @escaping () -> Void
) -> PageCellItem {
PageCellItem(
id: settingsExternalLink,
cellType: SettingsExternalLinkCell.self,
sizing: .fullWidth(estimatedHeight: 56),
viewModel: SettingsExternalLinkCellViewModel(
settingsExternalLink: settingsExternalLink,
onAction: onAction
)
)
}
}
================================================
FILE: Sources/SettingsPage/ExternalLinkCell/SettingsExternalLinkBusinessModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct SettingsExternalLinkBusinessModel: Sendable, Hashable {
let name: String
let info: String?
}
================================================
FILE: Sources/SettingsPage/ExternalLinkCell/SettingsExternalLinkCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class SettingsExternalLinkCell: SettingsCell, PageCell {
private enum Constants {
static let arrowSize = CGFloat(20)
}
private lazy var infoLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .caption1)
label.textColor = .secondaryLabel
return label
}()
private lazy var withInfoLabelConstraints = [
infoLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor),
infoLabel.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor),
infoLabel.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor),
infoLabel.bottomAnchor.constraint(equalTo: labelContainer.bottomAnchor),
]
override init(frame: CGRect) {
super.init(frame: frame)
accessoryView.image = UIImage(systemName: "arrow.up.forward")
accessoryView.tintColor = .label
accessoryView.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([
accessoryView.widthAnchor.constraint(equalToConstant: Constants.arrowSize),
accessoryView.heightAnchor.constraint(equalToConstant: Constants.arrowSize),
])
}
func configure(viewModel: SettingsExternalLinkCellViewModel) {
nameLabel.text = viewModel.name
infoLabel.removeFromSuperview()
NSLayoutConstraint.deactivate([nameLabelBottomConstraint] + withInfoLabelConstraints)
if let info = viewModel.info {
infoLabel.text = info
labelContainer.addSubview(infoLabel)
NSLayoutConstraint.activate(withInfoLabelConstraints)
} else {
NSLayoutConstraint.activate([nameLabelBottomConstraint])
}
}
}
================================================
FILE: Sources/SettingsPage/ExternalLinkCell/SettingsExternalLinkCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
final class SettingsExternalLinkCellViewModel: PageCellViewModel {
let name: String
let info: String?
let onAction: @Sendable () -> Void
init(
settingsExternalLink: SettingsExternalLinkBusinessModel,
onAction: @Sendable @escaping () -> Void
) {
self.name = settingsExternalLink.name
self.info = settingsExternalLink.info
self.onAction = onAction
}
func handle(action: PageCellAction) {
switch action {
case .tap: onAction()
}
}
}
================================================
FILE: Sources/SettingsPage/InfoCell/PageCellItem+settingsInfo.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func settingsInfo(
settingsInfo: SettingsInfoBusinessModel
) -> PageCellItem {
PageCellItem(
id: settingsInfo,
cellType: SettingsInfoCell.self,
sizing: .fullWidth(estimatedHeight: 56),
viewModel: SettingsInfoCellViewModel(settingsInfo: settingsInfo)
)
}
}
================================================
FILE: Sources/SettingsPage/InfoCell/SettingsInfoBusinessModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct SettingsInfoBusinessModel: Sendable, Hashable {
let name: String
let value: String
}
================================================
FILE: Sources/SettingsPage/InfoCell/SettingsInfoCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class SettingsInfoCell: SettingsCell, PageCell {
override init(frame: CGRect) {
super.init(frame: frame)
accessoryView.font = .preferredFont(forTextStyle: .body)
accessoryView.textAlignment = .right
accessoryView.textColor = .secondaryLabel
}
func configure(viewModel: SettingsInfoCellViewModel) {
nameLabel.text = viewModel.name
accessoryView.text = viewModel.value
}
}
================================================
FILE: Sources/SettingsPage/InfoCell/SettingsInfoCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
final class SettingsInfoCellViewModel: PageCellViewModel {
let name: String
let value: String
init(
settingsInfo: SettingsInfoBusinessModel
) {
self.name = settingsInfo.name
self.value = settingsInfo.value
}
func handle(action: PageCellAction) {
/* no-op */
}
}
================================================
FILE: Sources/SettingsPage/JottreGithubURL.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct JottreGithubURL: URLConvertible {
let scheme: String? = "https"
let host: String? = "github.com"
let path = "/antonlorani/jottre"
}
================================================
FILE: Sources/SettingsPage/SettingsCell/SettingsCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
class SettingsCell: UICollectionViewCell {
static var reuseIdentifier: String {
"SettingsCell<\(T.self)>"
}
let labelContainer: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let nameLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .body)
return label
}()
let accessoryView: T
private(set) lazy var nameLabelBottomConstraint = nameLabel.bottomAnchor.constraint(
equalTo: labelContainer.bottomAnchor
)
override init(frame: CGRect) {
self.accessoryView = T(frame: .zero)
super.init(frame: frame)
setUpViews()
}
init(frame: CGRect, accessoryView: T) {
self.accessoryView = accessoryView
super.init(frame: frame)
setUpViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
assertionFailure("\(#function) has not been implemented")
return nil
}
private func setUpViews() {
contentView.backgroundColor = .secondarySystemGroupedBackground
contentView.layer.cornerRadius = DesignTokens.CornerRadius.cell
contentView.clipsToBounds = true
contentView.layoutMargins = UIEdgeInsets(
top: .zero,
left: DesignTokens.Spacing.md,
bottom: .zero,
right: DesignTokens.Spacing.md
)
accessoryView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(labelContainer)
labelContainer.addSubview(nameLabel)
contentView.addSubview(accessoryView)
NSLayoutConstraint.activate([
labelContainer.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
labelContainer.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
labelContainer.trailingAnchor.constraint(
lessThanOrEqualTo: accessoryView.leadingAnchor,
constant: -DesignTokens.Spacing.xs
),
nameLabel.topAnchor.constraint(equalTo: labelContainer.topAnchor),
nameLabel.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor),
nameLabel.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor),
nameLabelBottomConstraint,
accessoryView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
accessoryView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
])
}
}
================================================
FILE: Sources/SettingsPage/SettingsCoordinator.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol SettingsCoordinatorProtocol: Coordinator {
func openExternalLink(url: URL)
func dismiss()
}
final class SettingsCoordinator: Coordinator, SettingsCoordinatorProtocol {
var onEnd: (() -> Void)?
private let navigation: Navigation
private let settingsViewControllerFactory: SettingsViewControllerFactoryProtocol
init(
navigation: Navigation,
settingsViewControllerFactory: SettingsViewControllerFactoryProtocol
) {
self.navigation = navigation
self.settingsViewControllerFactory = settingsViewControllerFactory
}
func start() {
let navigationController = UINavigationController(
rootViewController: settingsViewControllerFactory.make(coordinator: self)
)
navigationController.navigationBar.prefersLargeTitles = true
navigation.present(navigationController, animated: true)
}
func openExternalLink(url: URL) {
navigation.openExternal(url: url)
}
func dismiss() {
navigation.dismiss(
animated: true,
completion: { [weak self] in
Task { @MainActor in
self?.onEnd?()
}
}
)
}
}
================================================
FILE: Sources/SettingsPage/SettingsCoordinatorFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@MainActor
protocol SettingsCoordinatorFactoryProtocol {
func make(navigation: Navigation) -> Coordinator
}
struct SettingsCoordinatorFactory: SettingsCoordinatorFactoryProtocol {
let settingsViewControllerFactory: SettingsViewControllerFactoryProtocol
func make(navigation: Navigation) -> Coordinator {
SettingsCoordinator(
navigation: navigation,
settingsViewControllerFactory: settingsViewControllerFactory
)
}
}
================================================
FILE: Sources/SettingsPage/SettingsRepository.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
protocol SettingsRepositoryProtocol: Sendable {
func shouldShowEnableICloudButton() -> Bool
func appVersion() -> String
func userInterfaceStyle() -> AsyncStream
func updateUserInterfaceStyle(_ style: UIUserInterfaceStyle)
}
struct SettingsRepository: SettingsRepositoryProtocol {
private let ubiquitousFileService: FileServiceProtocol
private let bundleService: BundleServiceProtocol
private let defaultsService: DefaultsServiceProtocol
init(
ubiquitousFileService: FileServiceProtocol,
bundleService: BundleServiceProtocol,
defaultsService: DefaultsServiceProtocol
) {
self.ubiquitousFileService = ubiquitousFileService
self.bundleService = bundleService
self.defaultsService = defaultsService
}
func shouldShowEnableICloudButton() -> Bool {
!ubiquitousFileService.isEnabled()
}
func appVersion() -> String {
bundleService.shortVersionString() ?? "-"
}
func userInterfaceStyle() -> AsyncStream {
defaultsService.getValueStream(.userInterfaceStyle)
.map { value in
value
.flatMap { UIUserInterfaceStyle(rawValue: $0) }
?? .unspecified
}
.toAsyncStream()
}
func updateUserInterfaceStyle(_ style: UIUserInterfaceStyle) {
defaultsService.set(.userInterfaceStyle, value: style.rawValue)
}
}
================================================
FILE: Sources/SettingsPage/SettingsViewControllerFactory.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
protocol SettingsViewControllerFactoryProtocol: Sendable {
func make(coordinator: SettingsCoordinatorProtocol) -> UIViewController
}
struct SettingsViewControllerFactory: SettingsViewControllerFactoryProtocol {
let repository: SettingsRepositoryProtocol
let textBarButtonItemFactory: TextBarButtonItemFactory
let symbolBarButtonItemFactory: SymbolBarButtonItemFactory
func make(coordinator: SettingsCoordinatorProtocol) -> UIViewController {
let viewController = PageViewController(
viewModel: SettingsViewModel(
repository: repository,
coordinator: coordinator
),
textBarButtonItemFactory: textBarButtonItemFactory,
symbolBarButtonItemFactory: symbolBarButtonItemFactory
)
viewController.navigationItem.largeTitleDisplayMode = .always
return viewController
}
}
================================================
FILE: Sources/SettingsPage/SettingsViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@MainActor
final class SettingsViewModel: PageViewModel {
private enum Constants {
static let userInterfaceStyleOptions: [UIUserInterfaceStyle] = [.unspecified, .dark, .light]
}
var title: String? {
L10n.Settings.title
}
let rightNavigationItems: AsyncStream<[PageNavigationItem]>
private let rightNavigationItemsContinuation: AsyncStream<[PageNavigationItem]>.Continuation
let items: AsyncStream<[PageCellItem]>
private let itemsContinuation: AsyncStream<[PageCellItem]>.Continuation
private let repository: SettingsRepositoryProtocol
private weak var coordinator: SettingsCoordinatorProtocol?
private var loadingTask: Task?
init(
repository: SettingsRepositoryProtocol,
coordinator: SettingsCoordinatorProtocol
) {
self.repository = repository
self.coordinator = coordinator
(items, itemsContinuation) = AsyncStream.makeStream(
of: [PageCellItem].self,
bufferingPolicy: .bufferingNewest(1)
)
(rightNavigationItems, rightNavigationItemsContinuation) = AsyncStream.makeStream(
of: [PageNavigationItem].self,
bufferingPolicy: .bufferingNewest(1)
)
rightNavigationItemsContinuation.yield([
.symbol(systemImageName: "xmark") { [weak coordinator] in
Task { @MainActor in
coordinator?.dismiss()
}
}
])
}
func didLoad() {
loadingTask = Task { [weak self] in
guard let repository = self?.repository else {
return
}
let shouldShowEnableICloudButton = repository.shouldShowEnableICloudButton()
let appVersion = repository.appVersion()
for await userInterfaceStyle in repository.userInterfaceStyle() {
guard let self else {
return
}
itemsContinuation.yield(
makePageItems(
userInterfaceStyle: userInterfaceStyle,
shouldShowEnableICloudButton: shouldShowEnableICloudButton,
appVersion: appVersion
)
)
}
}
}
private func makePageItems(
userInterfaceStyle: UIUserInterfaceStyle,
shouldShowEnableICloudButton: Bool,
appVersion: String
) -> [PageCellItem] {
var items = [
PageCellItem.settingsDropdown(
settingsDropdown: SettingsDropdownBusinessModel(
name: L10n.Settings.Appearance.title,
current: SettingsDropdownBusinessModel.Option(
label: SettingsViewModel.makeLabel(userInterfaceStyle: userInterfaceStyle),
value: userInterfaceStyle
),
options: Constants.userInterfaceStyleOptions.map { userInterfaceStyleOption in
SettingsDropdownBusinessModel.Option(
label: SettingsViewModel.makeLabel(userInterfaceStyle: userInterfaceStyleOption),
value: userInterfaceStyleOption
)
}
),
onAction: { [weak self] option in
guard let style = option.value as? UIUserInterfaceStyle else {
return
}
self?.repository.updateUserInterfaceStyle(style)
}
)
]
if shouldShowEnableICloudButton {
items.append(
.settingsExternalLink(
settingsExternalLink: SettingsExternalLinkBusinessModel(
name: L10n.Settings.ICloud.title,
info: L10n.Settings.ICloud.info
),
onAction: { [weak self] in
Task { @MainActor [weak self] in
self?.coordinator?.openExternalLink(url: EnableICloudSupportURL().toURL())
}
}
)
)
}
items.append(contentsOf: [
.settingsExternalLink(
settingsExternalLink: SettingsExternalLinkBusinessModel(
name: L10n.Settings.Github.title,
info: nil
),
onAction: { [weak self] in
Task { @MainActor [weak self] in
self?.coordinator?.openExternalLink(url: JottreGithubURL().toURL())
}
}
),
.settingsInfo(
settingsInfo: SettingsInfoBusinessModel(
name: L10n.Settings.Version.title,
value: appVersion
)
),
])
return items
}
private static func makeLabel(userInterfaceStyle: UIUserInterfaceStyle) -> String {
switch userInterfaceStyle {
case .light:
L10n.Settings.Appearance.light
case .dark:
L10n.Settings.Appearance.dark
default:
L10n.Settings.Appearance.system
}
}
deinit {
loadingTask?.cancel()
}
}
================================================
FILE: Sources/SettingsPage/ToggleCell/PageCellItem+settingsToggle.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension PageCellItem {
@MainActor
static func settingsToggle(
settingsToggle: SettingsToggleBusinessModel
) -> PageCellItem {
PageCellItem(
id: settingsToggle,
cellType: SettingsToggleCell.self,
sizing: .fullWidth(estimatedHeight: 56),
viewModel: SettingsToggleCellViewModel(settingsToggle: settingsToggle)
)
}
}
================================================
FILE: Sources/SettingsPage/ToggleCell/SettingsToggleBusinessModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
struct SettingsToggleBusinessModel: Sendable, Hashable {
let name: String
let isOn: Bool
}
================================================
FILE: Sources/SettingsPage/ToggleCell/SettingsToggleCell.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
final class SettingsToggleCell: SettingsCell, PageCell {
func configure(viewModel: SettingsToggleCellViewModel) {
nameLabel.text = viewModel.name
accessoryView.isOn = viewModel.isOn
}
}
================================================
FILE: Sources/SettingsPage/ToggleCell/SettingsToggleCellViewModel.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
final class SettingsToggleCellViewModel: PageCellViewModel {
let name: String
let isOn: Bool
init(
settingsToggle: SettingsToggleBusinessModel
) {
self.name = settingsToggle.name
self.isOn = settingsToggle.isOn
}
func handle(action: PageCellAction) {
/* no-op */
}
}
================================================
FILE: Sources/Utilities/Array+safeIndex.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
================================================
FILE: Sources/Utilities/AsyncSequence+toAsyncThrowingStream.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
extension AsyncSequence where Self: Sendable, Element: Sendable {
func toAsyncThrowingStream() -> AsyncThrowingStream {
AsyncThrowingStream { continuation in
let task = Task {
do {
for try await element in self {
continuation.yield(element)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
func toAsyncStream() -> AsyncStream {
AsyncStream { continuation in
let task = Task {
do {
for try await element in self {
continuation.yield(element)
}
continuation.finish()
} catch {
continuation.finish()
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
}
================================================
FILE: Sources/Utilities/AsyncStream+debounce.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
extension AsyncSequence where Self: Sendable, Element: Sendable {
func debounce(
for seconds: TimeInterval
) -> AsyncStream {
AsyncStream { continuation in
let task = Task { [self] in
var currentTask: Task?
do {
for try await value in self {
currentTask?.cancel()
let captured = value
currentTask = Task {
do {
try await Task.sleep(nanoseconds: UInt64(seconds * Double(NSEC_PER_SEC)))
try Task.checkCancellation()
continuation.yield(captured)
} catch {
/* no-op */
}
}
}
} catch {
/* Upstream sequence threw — finish gracefully */
}
await currentTask?.value
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
}
================================================
FILE: Sources/Utilities/LoggerProtocol.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import OSLog
protocol LoggerProtocol: Sendable {
func debug(_ message: @autoclosure () -> String)
func info(_ message: @autoclosure () -> String)
func error(_ message: @autoclosure () -> String)
}
struct OSLogLogger: LoggerProtocol {
private let logger: Logger
init(category: String) {
self.logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "",
category: category
)
}
func debug(_ message: @autoclosure () -> String) {
#if DEBUG
let resolved = message()
logger.debug("\(resolved)")
#endif
}
func info(_ message: @autoclosure () -> String) {
let resolved = message()
logger.info("\(resolved)")
}
func error(_ message: @autoclosure () -> String) {
let resolved = message()
logger.error("\(resolved)")
}
}
================================================
FILE: Sources/Utilities/NSLayoutConstraint+withPriority.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
extension NSLayoutConstraint {
@inline(__always)
func withPriority(_ priority: UILayoutPriority) -> Self {
self.priority = priority
return `self`
}
}
================================================
FILE: Sources/Utilities/UIColor+adaptiveBlackWhite.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
extension UIColor {
static let adaptiveBlackWhite = UIColor { traits in
traits.userInterfaceStyle == .dark ? .black : .white
}
}
================================================
FILE: Sources/Utilities/UIFont+systemStyle.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
extension UIFont {
static func preferredFont(
forTextStyle style: TextStyle,
weight: Weight
) -> UIFont {
let size = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style).pointSize
let base = UIFont.systemFont(ofSize: size, weight: weight)
return UIFontMetrics(forTextStyle: style).scaledFont(for: base)
}
}
================================================
FILE: Sources/Utilities/UITraitCollection+hasRenderingChange.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
extension UITraitCollection {
func hasRenderingChange(comparedTo previous: UITraitCollection?) -> Bool {
userInterfaceStyle != previous?.userInterfaceStyle || displayScale != previous?.displayScale
}
}
================================================
FILE: Tests/CloudMigrationPage/CloudImageCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class CloudImageCellViewModelTests: XCTestCase {
func test_handleAction_givenTap_doesNothing() {
// Given
let viewModel = CloudImageCellViewModel()
// When
viewModel.handle(action: .tap)
// Then
XCTAssertNotNil(viewModel)
}
}
================================================
FILE: Tests/CloudMigrationPage/CloudMigrationCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class CloudMigrationCoordinatorTests: XCTestCase {
func test_shouldStart_givenRepositoryReturnsTrue_returnsTrue() {
// Given
let coordinator = CloudMigrationCoordinator(
repository: CloudMigrationRepositoryMock(getShouldShowCloudMigrationProvider: { true }),
navigation: Navigation.test(),
cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock(),
logger: LoggerMock()
)
// Then
XCTAssertTrue(coordinator.shouldStart())
}
func test_shouldStart_givenRepositoryReturnsFalse_returnsFalse() {
// Given
let coordinator = CloudMigrationCoordinator(
repository: CloudMigrationRepositoryMock(getShouldShowCloudMigrationProvider: { false }),
navigation: Navigation.test(),
cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock(),
logger: LoggerMock()
)
// Then
XCTAssertFalse(coordinator.shouldStart())
}
func test_start_givenInvoked_presentsNavigationControllerWithFactoryProducedRoot() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let madeViewController = UIViewController()
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
let navigationController = viewController as? UINavigationController
XCTAssertEqual(navigationController?.viewControllers.first, madeViewController)
presentExpectation.fulfill()
}
}
)
let coordinator = CloudMigrationCoordinator(
repository: CloudMigrationRepositoryMock(),
navigation: navigation,
cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock(
makeProvider: { _ in madeViewController }
),
logger: LoggerMock()
)
// When
coordinator.start()
// Then
wait(for: [presentExpectation], timeout: 1)
}
func test_dismiss_givenInvoked_invokesNavigationDismiss() {
// Given
let dismissExpectation = XCTestExpectation(description: "Navigation.dismiss is called.")
let navigation = Navigation.test(
dismissViewControllerProvider: { _, _ in dismissExpectation.fulfill() }
)
let coordinator = CloudMigrationCoordinator(
repository: CloudMigrationRepositoryMock(),
navigation: navigation,
cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock(),
logger: LoggerMock()
)
// When
coordinator.dismiss()
// Then
wait(for: [dismissExpectation], timeout: 1)
}
func test_dismiss_givenCompletion_invokesOnEnd() async {
// Given
let onEndExpectation = XCTestExpectation(description: "CloudMigrationCoordinator.onEnd is called.")
let navigation = Navigation.test(
dismissViewControllerProvider: { _, completion in
completion?()
}
)
let coordinator = CloudMigrationCoordinator(
repository: CloudMigrationRepositoryMock(),
navigation: navigation,
cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock(),
logger: LoggerMock()
)
coordinator.onEnd = { onEndExpectation.fulfill() }
// When
coordinator.dismiss()
// Then
await fulfillment(of: [onEndExpectation], timeout: 1)
}
func test_showInfoAlert_givenInvoked_presentsAlertController() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
XCTAssertTrue(viewController is UIAlertController)
presentExpectation.fulfill()
}
}
)
let coordinator = CloudMigrationCoordinator(
repository: CloudMigrationRepositoryMock(),
navigation: navigation,
cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock(),
logger: LoggerMock()
)
// When
coordinator.showInfoAlert(title: "title", message: "message")
// Then
wait(for: [presentExpectation], timeout: 1)
}
}
================================================
FILE: Tests/CloudMigrationPage/CloudMigrationJotBusinessModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class CloudMigrationJotBusinessModelTests: XCTestCase {
func test_init_givenLocalInfo_isUbiquitousFalseAndIsDownloadedTrue() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
// When
let model = CloudMigrationJotBusinessModel(jotFileInfo: info)
// Then
XCTAssertEqual(model.name, "note")
XCTAssertFalse(model.isUbiquitous)
XCTAssertTrue(model.isDownloaded)
XCTAssertFalse(model.isDownloading)
XCTAssertEqual(model.lastModifiedText, "")
}
func test_init_givenUbiquitousInfoNotDownloaded_isUbiquitousTrueAndIsDownloadedFalse() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///cloud/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: true)
)
// When
let model = CloudMigrationJotBusinessModel(jotFileInfo: info)
// Then
XCTAssertTrue(model.isUbiquitous)
XCTAssertFalse(model.isDownloaded)
XCTAssertTrue(model.isDownloading)
}
func test_init_givenModificationDate_formatsLastModifiedText() {
// Given
let date = Date(timeIntervalSince1970: 0)
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: date,
ubiquitousInfo: nil
)
// When
let model = CloudMigrationJotBusinessModel(jotFileInfo: info)
// Then
XCTAssertFalse(model.lastModifiedText.isEmpty)
XCTAssertEqual(
model.lastModifiedText,
DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .short)
)
}
func test_toJotFileInfo_returnsOriginalInfo() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
// When
let model = CloudMigrationJotBusinessModel(jotFileInfo: info)
// Then
XCTAssertEqual(model.toJotFileInfo(), info)
}
}
================================================
FILE: Tests/CloudMigrationPage/CloudMigrationJotCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class CloudMigrationJotCellViewModelTests: XCTestCase {
func test_init_storesNameInfoTextAndCloudFlagsFromBusinessModel() {
// Given
let date = Date(timeIntervalSince1970: 1_700_000_000)
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///cloud/note.jot"),
name: "note",
modificationDate: date,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
)
let businessModel = CloudMigrationJotBusinessModel(jotFileInfo: jotFileInfo)
let expectedInfoText = DateFormatter.localizedString(
from: date,
dateStyle: .long,
timeStyle: .short
)
// When
let viewModel = CloudMigrationJotCellViewModel(
cloudMigrationJot: businessModel,
repository: CloudMigrationRepositoryMock(),
onTap: {}
)
// Then
XCTAssertEqual(viewModel.name, "note")
XCTAssertEqual(viewModel.infoText, expectedInfoText)
XCTAssertTrue(viewModel.isCloudCheckboxOn)
XCTAssertFalse(viewModel.isDownloading)
}
func test_init_givenLocalJotFile_setsCloudCheckboxOff() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
// When
let viewModel = CloudMigrationJotCellViewModel(
cloudMigrationJot: CloudMigrationJotBusinessModel(jotFileInfo: jotFileInfo),
repository: CloudMigrationRepositoryMock(),
onTap: {}
)
// Then
XCTAssertFalse(viewModel.isCloudCheckboxOn)
XCTAssertEqual(viewModel.infoText, "")
}
func test_handleAction_givenTap_invokesOnTap() async {
// Given
let onTapExpectation = XCTestExpectation(description: "onTap is called.")
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let viewModel = CloudMigrationJotCellViewModel(
cloudMigrationJot: CloudMigrationJotBusinessModel(jotFileInfo: jotFileInfo),
repository: CloudMigrationRepositoryMock(),
onTap: { onTapExpectation.fulfill() }
)
// When
viewModel.handle(action: .tap)
// Then
await fulfillment(of: [onTapExpectation], timeout: 0.2)
}
func test_getPreviewImage_forwardsJotFileInfoToRepository() async throws {
// Given
let getPreviewImageExpectation = XCTestExpectation(
description: "CloudMigrationRepositoryMock.getPreviewImageProvider is called."
)
let expectedImage = UIImage()
let expectedFileURL = URL(staticString: "file:///tmp/note.jot")
let jotFileInfo = JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let repositoryMock = CloudMigrationRepositoryMock(
getPreviewImageProvider: { receivedInfo, receivedStyle, receivedScale in
// Then
XCTAssertEqual(receivedInfo.url, expectedFileURL)
XCTAssertEqual(receivedStyle, .light)
XCTAssertEqual(receivedScale, 2.0)
getPreviewImageExpectation.fulfill()
return expectedImage
}
)
let viewModel = CloudMigrationJotCellViewModel(
cloudMigrationJot: CloudMigrationJotBusinessModel(jotFileInfo: jotFileInfo),
repository: repositoryMock,
onTap: {}
)
// When
let image = await viewModel.getPreviewImage(userInterfaceStyle: .light, displayScale: 2.0)
// Then
XCTAssertIdentical(image, expectedImage)
await fulfillment(of: [getPreviewImageExpectation], timeout: 0.2)
}
}
================================================
FILE: Tests/CloudMigrationPage/CloudMigrationRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class CloudMigrationRepositoryTests: XCTestCase {
private static let localInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/local.jot"),
name: "local",
modificationDate: Date(timeIntervalSince1970: 1_000),
ubiquitousInfo: nil
)
private static let ubiquitousNewer = JotFile.Info(
url: URL(staticString: "file:///cloud/newer.jot"),
name: "newer",
modificationDate: Date(timeIntervalSince1970: 3_000),
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
)
private static let ubiquitousOlder = JotFile.Info(
url: URL(staticString: "file:///cloud/older.jot"),
name: "older",
modificationDate: Date(timeIntervalSince1970: 2_000),
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
)
func test_getJotFiles_yieldsLocalsBeforeUbiquitousAndUbiquitousSortedNewestFirst() async throws {
// Given
let jotFileServiceMock = JotFileServiceMock(
documentsDirectoryContentsProvider: {
AsyncThrowingStream { continuation in
continuation.yield([Self.ubiquitousNewer, Self.localInfo, Self.ubiquitousOlder])
continuation.finish()
}
}
)
let repository = CloudMigrationRepository(
ubiquitousFileService: FileServiceMock(),
jotFileService: jotFileServiceMock,
jotFilePreviewImageService: JotFilePreviewImageServiceMock(),
defaultsService: DefaultsServiceMock()
)
// When
var iterator = repository.getJotFiles().makeAsyncIterator()
let first = try await XCTUnwrapAsync(try await iterator.next())
// Then
XCTAssertEqual(first.map(\.name), ["local", "newer", "older"])
}
func test_moveJotFile_forwardsToJotFileService() async throws {
// Given
let moveProviderExpectation = XCTestExpectation(description: "moveProvider is called.")
let jotFileServiceMock = JotFileServiceMock(
moveProvider: { receivedInfo, receivedShouldBecomeUbiquitous in
// Then
XCTAssertEqual(receivedInfo, Self.localInfo)
XCTAssertTrue(receivedShouldBecomeUbiquitous)
moveProviderExpectation.fulfill()
}
)
let repository = CloudMigrationRepository(
ubiquitousFileService: FileServiceMock(),
jotFileService: jotFileServiceMock,
jotFilePreviewImageService: JotFilePreviewImageServiceMock(),
defaultsService: DefaultsServiceMock()
)
// When
try await repository.moveJotFile(jotFileInfo: Self.localInfo, shouldBecomeUbiquitous: true)
// Then
await fulfillment(of: [moveProviderExpectation], timeout: 0.2)
}
func test_getShouldShowCloudMigration_givenAlreadyDone_returnsFalse() {
// Given
let defaultsServiceMock = DefaultsServiceMock(
initialValues: [DefaultsKey.hasDoneCloudMigration.description: true]
)
let repository = CloudMigrationRepository(
ubiquitousFileService: FileServiceMock(isEnabledProvider: { false }),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock(),
defaultsService: defaultsServiceMock
)
// Then
XCTAssertFalse(repository.getShouldShowCloudMigration())
}
func test_getShouldShowCloudMigration_givenStoredFlagDiffersFromCurrentUbiquitousState_returnsTrue() {
// Given
let defaultsServiceMock = DefaultsServiceMock(
initialValues: [DefaultsKey.isICloudEnabled.description: false]
)
let repository = CloudMigrationRepository(
ubiquitousFileService: FileServiceMock(isEnabledProvider: { true }),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock(),
defaultsService: defaultsServiceMock
)
// Then
XCTAssertTrue(repository.getShouldShowCloudMigration())
}
func test_getShouldShowCloudMigration_givenStoredFlagMatchesCurrentUbiquitousState_returnsFalse() {
// Given
let defaultsServiceMock = DefaultsServiceMock(
initialValues: [DefaultsKey.isICloudEnabled.description: true]
)
let repository = CloudMigrationRepository(
ubiquitousFileService: FileServiceMock(isEnabledProvider: { true }),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock(),
defaultsService: defaultsServiceMock
)
// Then
XCTAssertFalse(repository.getShouldShowCloudMigration())
}
func test_getShouldShowCloudMigration_givenNoStoredFlagAndUbiquitousDisabled_persistsFalseAndReturnsFalse() {
// Given
let defaultsServiceMock = DefaultsServiceMock()
let repository = CloudMigrationRepository(
ubiquitousFileService: FileServiceMock(isEnabledProvider: { false }),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock(),
defaultsService: defaultsServiceMock
)
// When
let result = repository.getShouldShowCloudMigration()
// Then
XCTAssertFalse(result)
XCTAssertEqual(defaultsServiceMock.getValue(.isICloudEnabled), false)
}
func test_markCloudMigrationPageDone_setsHasDoneCloudMigrationToTrue() {
// Given
let defaultsServiceMock = DefaultsServiceMock()
let repository = CloudMigrationRepository(
ubiquitousFileService: FileServiceMock(),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock(),
defaultsService: defaultsServiceMock
)
// When
repository.markCloudMigrationPageDone()
// Then
XCTAssertEqual(defaultsServiceMock.getValue(.hasDoneCloudMigration), true)
}
}
private func XCTUnwrapAsync(
_ expression: @autoclosure () async throws -> T?,
file: StaticString = #filePath,
line: UInt = #line
) async throws -> T {
let value = try await expression()
return try XCTUnwrap(value, file: file, line: line)
}
================================================
FILE: Tests/CloudMigrationPage/CloudMigrationViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class CloudMigrationViewModelTests: XCTestCase {
func test_didLoad_givenEmptyJotFiles_yieldsCloudImageAndHeader() async throws {
// Given
let stream = AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> { continuation in
continuation.yield([])
continuation.finish()
}
let viewModel = CloudMigrationViewModel(
repository: CloudMigrationRepositoryMock(getJotFilesProvider: { stream }),
coordinator: CloudMigrationCoordinatorMock(),
logger: LoggerMock()
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
// Then
XCTAssertEqual(items.count, 2)
}
func test_didLoad_givenOneJot_yieldsHeaderAndJotItem() async throws {
// Given
let businessModel = CloudMigrationJotBusinessModel(
jotFileInfo: JotFile.Info(
url: URL(staticString: "file:///tmp/foo.jot"),
name: "foo",
modificationDate: Date(timeIntervalSince1970: 0),
ubiquitousInfo: nil
)
)
let stream = AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> { continuation in
continuation.yield([businessModel])
continuation.finish()
}
let viewModel = CloudMigrationViewModel(
repository: CloudMigrationRepositoryMock(getJotFilesProvider: { stream }),
coordinator: CloudMigrationCoordinatorMock(),
logger: LoggerMock()
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
// Then
XCTAssertEqual(items.count, 2)
}
func test_didLoad_givenStreamThrows_logsError() async {
// Given
let errorExpectation = XCTestExpectation(description: "LoggerMock.errorProvider is called.")
let stream = AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> { continuation in
continuation.finish(throwing: NSError(domain: "test", code: 0))
}
let viewModel = CloudMigrationViewModel(
repository: CloudMigrationRepositoryMock(getJotFilesProvider: { stream }),
coordinator: CloudMigrationCoordinatorMock(),
logger: LoggerMock(
errorProvider: { message in
if message.contains("Failed to observe migration jot files") {
errorExpectation.fulfill()
}
}
)
)
// When
viewModel.didLoad()
// Then
await fulfillment(of: [errorExpectation], timeout: 1)
}
func test_actions_givenDoneTap_marksDoneAndDismissesViaCoordinator() async {
// Given
let markDoneExpectation = XCTestExpectation(description: "Repository.markCloudMigrationPageDone is called.")
let dismissExpectation =
XCTestExpectation(description: "CloudMigrationCoordinatorMock.dismiss is called.")
let coordinator = CloudMigrationCoordinatorMock(
dismissProvider: { dismissExpectation.fulfill() }
)
let viewModel = CloudMigrationViewModel(
repository: CloudMigrationRepositoryMock(
markCloudMigrationPageDoneProvider: { markDoneExpectation.fulfill() }
),
coordinator: coordinator,
logger: LoggerMock()
)
// When
XCTAssertEqual(viewModel.actions.count, 1)
viewModel.actions[0].action()
// Then
await fulfillment(of: [markDoneExpectation, dismissExpectation], timeout: 1)
}
}
@MainActor
private func firstValue(
of sequence: S
) async throws -> S.Element where S.Element: Sendable {
var iterator = sequence.makeAsyncIterator()
guard let value = try await iterator.next() else {
throw NSError(domain: "CloudMigrationViewModelTests", code: 0)
}
return value
}
================================================
FILE: Tests/Defaults/DefaultsContinuationStorageTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class DefaultsContinuationStorageTests: XCTestCase {
func test_continuations_givenNoRegistrations_returnsNil() {
// Given
let storage = DefaultsContinuationStorage()
let key = DefaultsKey("none")
// When
let result = storage.continuations(defaultsKey: key)
// Then
XCTAssertNil(result)
}
func test_add_givenContinuationForKey_continuationsReturnsIt() throws {
// Given
let storage = DefaultsContinuationStorage()
let key = DefaultsKey("k")
let stream = AsyncStream { continuation in
storage.add(continuation, defaultsKey: key)
}
let iterator = stream.makeAsyncIterator()
_ = iterator
// When
let result = try XCTUnwrap(storage.continuations(defaultsKey: key))
// Then
XCTAssertEqual(result.count, 1)
}
func test_addMultiple_givenSameKey_continuationsReturnsAll() throws {
// Given
let storage = DefaultsContinuationStorage()
let key = DefaultsKey("k")
let stream1 = AsyncStream { continuation in
storage.add(continuation, defaultsKey: key)
}
let stream2 = AsyncStream { continuation in
storage.add(continuation, defaultsKey: key)
}
let iterator1 = stream1.makeAsyncIterator()
let iterator2 = stream2.makeAsyncIterator()
_ = iterator1
_ = iterator2
// When
let result = try XCTUnwrap(storage.continuations(defaultsKey: key))
// Then
XCTAssertEqual(result.count, 2)
}
func test_continuations_givenDifferentKey_returnsNil() {
// Given
let storage = DefaultsContinuationStorage()
let registeredKey = DefaultsKey("k1")
let otherKey = DefaultsKey("k2")
let stream = AsyncStream { continuation in
storage.add(continuation, defaultsKey: registeredKey)
}
let iterator = stream.makeAsyncIterator()
_ = iterator
// When
let result = storage.continuations(defaultsKey: otherKey)
// Then
XCTAssertNil(result)
}
}
================================================
FILE: Tests/Defaults/DefaultsKeyTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class DefaultsKeyTests: XCTestCase {
func test_init_givenString_setsDescription() {
// When
let key = DefaultsKey("someKey")
// Then
XCTAssertEqual(key.description, "someKey")
}
func test_initStringLiteral_setsDescription() {
// When
let key: DefaultsKey = "literalKey"
// Then
XCTAssertEqual(key.description, "literalKey")
}
func test_isICloudEnabled_returnsExpectedDescription() {
// When
let key = DefaultsKey.isICloudEnabled
// Then
XCTAssertEqual(key.description, "isICloudEnabled")
}
func test_userInterfaceStyle_returnsExpectedDescription() {
// When
let key = DefaultsKey.userInterfaceStyle
// Then
XCTAssertEqual(key.description, "userInterfaceStyle")
}
func test_hasDoneCloudMigration_returnsExpectedDescription() {
// When
let key = DefaultsKey.hasDoneCloudMigration
// Then
XCTAssertEqual(key.description, "hasDoneCloudMigration")
}
}
================================================
FILE: Tests/Defaults/DefaultsServiceTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class DefaultsServiceTests: XCTestCase {
private var userDefaults: UserDefaults!
private var suiteName: String!
override func setUp() {
super.setUp()
suiteName = "DefaultsServiceTests-\(UUID().uuidString)"
userDefaults = try! XCTUnwrap(UserDefaults(suiteName: suiteName))
}
override func tearDown() {
userDefaults.removePersistentDomain(forName: suiteName)
userDefaults = nil
suiteName = nil
super.tearDown()
}
func test_getValue_givenNoStoredValue_returnsNil() {
// Given
let service = DefaultsService(userDefaults: userDefaults)
// When
let value: Bool? = service.getValue(DefaultsKey("missing"))
// Then
XCTAssertNil(value)
}
func test_set_givenBoolValue_persistsAsRoundTrippableString() throws {
// Given
let service = DefaultsService(userDefaults: userDefaults)
let key = DefaultsKey("flag")
// When
service.set(key, value: true)
// Then
let stored = try XCTUnwrap(service.getValue(key))
XCTAssertTrue(stored)
}
func test_set_givenNilValue_clearsValue() {
// Given
let service = DefaultsService(userDefaults: userDefaults)
let key = DefaultsKey("count")
service.set(key, value: 42)
// When
service.set(key, value: nil)
// Then
XCTAssertNil(service.getValue(key))
}
func test_getValueStream_yieldsCurrentValueImmediately() async throws {
// Given
let service = DefaultsService(userDefaults: userDefaults)
let key = DefaultsKey("preset")
service.set(key, value: 7)
// When
var iterator = service.getValueStream(key).makeAsyncIterator()
let first = try await XCTUnwrapAsync(await iterator.next())
// Then
XCTAssertEqual(first, 7)
}
func test_getValueStream_yieldsValueOnSubsequentSet() async throws {
// Given
let service = DefaultsService(userDefaults: userDefaults)
let key = DefaultsKey("counter")
var iterator = service.getValueStream(key).makeAsyncIterator()
_ = await iterator.next()
// When
service.set(key, value: 99)
let next = try await XCTUnwrapAsync(await iterator.next())
// Then
XCTAssertEqual(next, 99)
}
}
private func XCTUnwrapAsync(
_ expression: @autoclosure () async throws -> T?,
file: StaticString = #filePath,
line: UInt = #line
) async throws -> T {
let value = try await expression()
return try XCTUnwrap(value, file: file, line: line)
}
================================================
FILE: Tests/EditJotPage/EditJotCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class EditJotCoordinatorTests: XCTestCase {
func test_shouldHandle_givenEditJotURL_returnsTrue() {
// Given
let coordinator = makeCoordinator()
let url = EditJotURL(
jotFileInfo: JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
).toURL()
// Then
XCTAssertTrue(coordinator.shouldHandle(url: url))
}
func test_shouldHandle_givenNonEditJotURL_returnsFalse() {
// Given
let coordinator = makeCoordinator()
// Then
XCTAssertFalse(coordinator.shouldHandle(url: URL(staticString: "https://example.com")))
}
func test_handle_givenValidURL_returnsViewControllerFromFactory() {
// Given
let madeViewController = UIViewController()
let coordinator = makeCoordinator(
editJotViewControllerFactory: EditJotViewControllerFactoryMock(
makeProvider: { _, _ in madeViewController }
)
)
let url = EditJotURL(
jotFileInfo: JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
).toURL()
// When
let viewControllers = coordinator.handle(url: url)
// Then
XCTAssertEqual(viewControllers, [madeViewController])
}
func test_handle_givenUnparsableURL_returnsEmpty() {
// Given
let coordinator = makeCoordinator()
// When
let viewControllers = coordinator.handle(url: URL(staticString: "https://example.com"))
// Then
XCTAssertTrue(viewControllers.isEmpty)
}
func test_openJot_givenInvoked_invokesNavigationOpenWithEditJotURL() {
// Given
let openExpectation = XCTestExpectation(description: "Navigation.open is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
openURLProvider: { receivedURL in
XCTAssertEqual(receivedURL, EditJotURL(jotFileInfo: info).toURL())
openExpectation.fulfill()
}
)
let coordinator = makeCoordinator(navigation: navigation)
// When
coordinator.openJot(jotFileInfo: info)
// Then
wait(for: [openExpectation], timeout: 1)
}
func test_canGoBack_givenMultipleViewControllers_returnsTrue() {
// Given
let navigation = Navigation.test(
getViewControllersProvider: { [UIViewController(), UIViewController()] }
)
let coordinator = makeCoordinator(navigation: navigation)
// Then
XCTAssertTrue(coordinator.canGoBack())
}
func test_canGoBack_givenSingleViewController_returnsFalse() {
// Given
let navigation = Navigation.test(
getViewControllersProvider: { [UIViewController()] }
)
let coordinator = makeCoordinator(navigation: navigation)
// Then
XCTAssertFalse(coordinator.canGoBack())
}
func test_goBack_givenInvoked_invokesNavigationPop() {
// Given
let popExpectation = XCTestExpectation(description: "Navigation.popViewController is called.")
let navigation = Navigation.test(
popViewControllerProvider: { animated in
XCTAssertTrue(animated)
popExpectation.fulfill()
}
)
let coordinator = makeCoordinator(navigation: navigation)
// When
coordinator.goBack()
// Then
wait(for: [popExpectation], timeout: 1)
}
func test_showShareJot_givenInvoked_startsShareCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "ShareJot Coordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
shareJotCoordinatorFactory: ShareJotCoordinatorFactoryMock(
makeProvider: { _, _, _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.showShareJot(jotFileInfo: info, format: .pdf, configurePopoverAnchor: nil)
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_openDeleteJot_givenInvoked_startsDeleteCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "DeleteJot Coordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryMock(
makeProvider: { _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.openDeleteJot(jotFileInfo: info)
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_showRenameAlert_givenInvoked_startsRenameCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "RenameJot Coordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
renameJotCoordinatorFactory: RenameJotCoordinatorFactoryMock(
makeProvider: { _, _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.showRenameAlert(jotFileInfo: info)
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_showInFiles_givenInvoked_startsRevealFileCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "RevealFile Coordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
revealFileCoordinatorFactory: RevealFileCoordinatorFactoryMock(
makeProvider: { _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.showInFiles(jotFileInfo: info)
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_showJotConflictPage_givenInvoked_startsJotConflictCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "JotConflict Coordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
jotConflictCoordinatorFactory: JotConflictCoordinatorFactoryMock(
makeProvider: { _, _, _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.showJotConflictPage(jotFileInfo: info, jotFileVersions: []) { _ in }
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_showInfoAlert_givenInvoked_presentsAlertController() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
XCTAssertTrue(viewController is UIAlertController)
presentExpectation.fulfill()
}
}
)
let coordinator = makeCoordinator(navigation: navigation)
// When
coordinator.showInfoAlert(title: "title", message: "message")
// Then
wait(for: [presentExpectation], timeout: 1)
}
private func makeCoordinator(
navigation: Navigation = .test(),
repository: EditJotRepositoryProtocol = EditJotRepositoryMock(),
editJotViewControllerFactory: EditJotViewControllerFactoryProtocol = EditJotViewControllerFactoryMock(),
jotConflictCoordinatorFactory: JotConflictCoordinatorFactoryProtocol = JotConflictCoordinatorFactoryMock(),
renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol = RenameJotCoordinatorFactoryMock(),
deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol = DeleteJotCoordinatorFactoryMock(),
shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol = ShareJotCoordinatorFactoryMock(),
revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol = RevealFileCoordinatorFactoryMock()
) -> EditJotCoordinator {
EditJotCoordinator(
navigation: navigation,
repository: repository,
editJotViewControllerFactory: editJotViewControllerFactory,
jotConflictCoordinatorFactory: jotConflictCoordinatorFactory,
renameJotCoordinatorFactory: renameJotCoordinatorFactory,
deleteJotCoordinatorFactory: deleteJotCoordinatorFactory,
shareJotCoordinatorFactory: shareJotCoordinatorFactory,
revealFileCoordinatorFactory: revealFileCoordinatorFactory
)
}
}
================================================
FILE: Tests/EditJotPage/EditJotRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@preconcurrency import PencilKit
import XCTest
@testable import Jottre
final class EditJotRepositoryTests: XCTestCase {
func test_ubiquitousInfo_forwardsToUbiquitousFileService() {
// Given
let expectedInfo = UbiquitousInfo(downloadStatus: .current, isDownloading: false)
let url = URL(staticString: "file:///cloud/note.jot")
let repository = EditJotRepository(
ubiquitousFileService: FileServiceMock(
ubiquitousInfoProvider: { receivedURL in
XCTAssertEqual(receivedURL, url)
return expectedInfo
}
),
jotFileService: JotFileServiceMock(),
jotFileConflictService: JotFileConflictServiceMock()
)
// When
let result = repository.ubiquitousInfo(url: url)
// Then
XCTAssertEqual(result, expectedInfo)
}
func test_readDrawing_givenValidJotFile_returnsDrawingAndWidth() async throws {
// Given
let drawing = PKDrawing()
let drawingData = drawing.dataRepresentation()
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let repository = EditJotRepository(
ubiquitousFileService: FileServiceMock(),
jotFileService: JotFileServiceMock(
readJotFileProvider: { _ in
JotFile(
info: info,
jot: Jot(version: 3, drawing: drawingData, width: 800)
)
}
),
jotFileConflictService: JotFileConflictServiceMock()
)
// When
let result = try await repository.readDrawing(jotFileInfo: info)
// Then
XCTAssertEqual(result.width, 800)
XCTAssertEqual(result.drawing.strokes.count, drawing.strokes.count)
}
func test_writeDrawing_writesEncodedDrawingViaJotFileService() async throws {
// Given
let writeProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.writeProvider is called.")
let drawing = PKDrawing()
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let repository = EditJotRepository(
ubiquitousFileService: FileServiceMock(),
jotFileService: JotFileServiceMock(
writeProvider: { jotFile in
// Then
XCTAssertEqual(jotFile.info, info)
XCTAssertEqual(jotFile.jot.drawing, drawing.dataRepresentation())
writeProviderExpectation.fulfill()
}
),
jotFileConflictService: JotFileConflictServiceMock()
)
// When
try await repository.writeDrawing(jotFileInfo: info, drawing: drawing)
// Then
await fulfillment(of: [writeProviderExpectation], timeout: 0.2)
}
func test_getConflictingVersions_forwardsToConflictService() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let expected = [
JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info)
]
let repository = EditJotRepository(
ubiquitousFileService: FileServiceMock(),
jotFileService: JotFileServiceMock(),
jotFileConflictService: JotFileConflictServiceMock(
getConfictingVersionsProvider: { receivedInfo in
XCTAssertEqual(receivedInfo, info)
return expected
}
)
)
// When
let result = repository.getConflictingVersions(jotFileInfo: info)
// Then
XCTAssertEqual(result, expected)
}
func test_duplicate_forwardsToJotFileService() throws {
// Given
let original = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let duplicated = JotFile.Info(
url: URL(staticString: "file:///tmp/note-1.jot"),
name: "note-1",
modificationDate: nil,
ubiquitousInfo: nil
)
let repository = EditJotRepository(
ubiquitousFileService: FileServiceMock(),
jotFileService: JotFileServiceMock(
duplicateProvider: { receivedInfo in
XCTAssertEqual(receivedInfo, original)
return duplicated
}
),
jotFileConflictService: JotFileConflictServiceMock()
)
// When
let result = try repository.duplicate(jotFileInfo: original)
// Then
XCTAssertEqual(result, duplicated)
}
}
================================================
FILE: Tests/EditJotPage/EditJotViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@preconcurrency import PencilKit
import XCTest
@testable import Jottre
@MainActor
final class EditJotViewModelTests: XCTestCase {
func test_didLoad_givenNoConflictingVersions_yieldsDrawingFromRepository() async throws {
// Given
let drawing = PKDrawing()
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(
readDrawingProvider: { _ in (drawing, 1024) }
),
coordinator: EditJotCoordinatorMock(),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
viewModel.didLoad()
// Then
var iterator = viewModel.drawing.makeAsyncIterator()
let nextValue = await iterator.next()
let received = try XCTUnwrap(nextValue)
XCTAssertEqual(received.width, 1024)
}
func test_didLoad_givenReadDrawingThrows_logsError() async {
// Given
let errorExpectation = XCTestExpectation(description: "LoggerMock.errorProvider is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(
readDrawingProvider: { _ in throw NSError(domain: "test", code: 0) }
),
coordinator: EditJotCoordinatorMock(),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock(
errorProvider: { message in
if message.contains("Failed to read drawing") {
errorExpectation.fulfill()
}
}
)
)
// When
viewModel.didLoad()
// Then
await fulfillment(of: [errorExpectation], timeout: 1)
}
func test_didLoad_givenConflictingVersions_invokesShowJotConflictPage() async {
// Given
let conflictExpectation = XCTestExpectation(description: "Coordinator.showJotConflictPage is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = EditJotCoordinatorMock(
showJotConflictPageProvider: { _, _, _ in conflictExpectation.fulfill() }
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(
getConflictingVersionsProvider: { _ in
[JotFileVersion(localizedNameOfSavingComputer: nil, info: info)]
}
),
coordinator: coordinator,
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
viewModel.didLoad()
// Then
await fulfillment(of: [conflictExpectation], timeout: 1)
}
func test_didTapBackButton_givenNoConflicts_invokesGoBack() async {
// Given
let goBackExpectation = XCTestExpectation(description: "Coordinator.goBack is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = EditJotCoordinatorMock(
goBackProvider: { goBackExpectation.fulfill() }
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(),
coordinator: coordinator,
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
viewModel.didTapBackButton()
// Then
await fulfillment(of: [goBackExpectation], timeout: 1)
}
func test_didTapBackButton_givenConflicts_invokesShowJotConflictPage() async {
// Given
let conflictExpectation = XCTestExpectation(description: "Coordinator.showJotConflictPage is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = EditJotCoordinatorMock(
showJotConflictPageProvider: { _, _, _ in conflictExpectation.fulfill() }
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(
getConflictingVersionsProvider: { _ in
[JotFileVersion(localizedNameOfSavingComputer: nil, info: info)]
}
),
coordinator: coordinator,
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
viewModel.didTapBackButton()
// Then
await fulfillment(of: [conflictExpectation], timeout: 1)
}
func test_showsBackButton_givenDidLoadAndCanGoBack_yieldsTrue() async throws {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = EditJotCoordinatorMock(
canGoBackProvider: { true }
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(),
coordinator: coordinator,
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
viewModel.didLoad()
var iterator = viewModel.showsBackButton.makeAsyncIterator()
let nextValue = await iterator.next()
let value = try XCTUnwrap(nextValue)
// Then
XCTAssertTrue(value)
}
func test_didTapToggleEditingButton_givenFalse_yieldsTrue() async throws {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(),
coordinator: EditJotCoordinatorMock(),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
viewModel.didTapToggleEditingButton(isEditing: false)
// Then
var iterator = viewModel.isEditing.makeAsyncIterator()
let nextValue = await iterator.next()
let firstValue = try XCTUnwrap(nextValue)
let value = try XCTUnwrap(firstValue)
XCTAssertTrue(value)
}
func test_menuConfigurations_givenDuplicateActionThrows_invokesShowInfoAlert() async {
// Given
let alertExpectation = XCTestExpectation(description: "Coordinator.showInfoAlert is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = EditJotCoordinatorMock(
showInfoAlertProvider: { _, _ in alertExpectation.fulfill() }
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(
duplicateProvider: { _ in throw NSError(domain: "test", code: 0) }
),
coordinator: coordinator,
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When (find duplicate action and invoke)
let configurations = viewModel.menuConfigurations.make(popoverAnchorProvider: { nil })
let duplicateAction = configurations.compactMap { configuration -> JotMenuConfiguration.Action? in
if case let .action(action) = configuration, action.systemImageName == "plus.square.on.square" {
return action
}
return nil
}.first
try? XCTUnwrap(duplicateAction).handler()
await Task.yield()
// Then
await fulfillment(of: [alertExpectation], timeout: 1)
}
func test_menuConfigurations_givenDuplicateActionSucceeds_invokesOpenJot() async {
// Given
let openJotExpectation = XCTestExpectation(description: "Coordinator.openJot is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let duplicatedInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note 1.jot"),
name: "note 1",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = EditJotCoordinatorMock(
openJotProvider: { received in
XCTAssertEqual(received, duplicatedInfo)
openJotExpectation.fulfill()
}
)
let viewModel = EditJotViewModel(
jotFileInfo: info,
repository: EditJotRepositoryMock(
duplicateProvider: { _ in duplicatedInfo }
),
coordinator: coordinator,
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
let configurations = viewModel.menuConfigurations.make(popoverAnchorProvider: { nil })
let duplicateAction = configurations.compactMap { configuration -> JotMenuConfiguration.Action? in
if case let .action(action) = configuration, action.systemImageName == "plus.square.on.square" {
return action
}
return nil
}.first
try? XCTUnwrap(duplicateAction).handler()
await Task.yield()
// Then
await fulfillment(of: [openJotExpectation], timeout: 1)
}
}
================================================
FILE: Tests/EnableCloudPage/EnableCloudCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class EnableCloudCoordinatorTests: XCTestCase {
func test_start_givenInvoked_presentsNavigationControllerWithFactoryProducedRoot() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let madeViewController = UIViewController()
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, animated in
MainActor.assumeIsolated {
// Then
XCTAssertTrue(viewController is UINavigationController)
let navigationController = viewController as? UINavigationController
XCTAssertEqual(navigationController?.viewControllers.first, madeViewController)
XCTAssertTrue(animated)
presentExpectation.fulfill()
}
}
)
let coordinator = EnableCloudCoordinator(
navigation: navigation,
enableCloudViewControllerFactory: EnableCloudViewControllerFactoryMock(
makeProvider: { _ in madeViewController }
)
)
// When
coordinator.start()
// Then
wait(for: [presentExpectation], timeout: 1)
}
func test_openLearnHowToEnable_givenInvoked_invokesNavigationOpenExternalWithSupportURL() {
// Given
let openExpectation = XCTestExpectation(description: "Navigation.openExternal is called.")
let navigation = Navigation.test(
openExternalURLProvider: { receivedURL in
// Then
XCTAssertEqual(receivedURL, EnableICloudSupportURL().toURL())
openExpectation.fulfill()
}
)
let coordinator = EnableCloudCoordinator(
navigation: navigation,
enableCloudViewControllerFactory: EnableCloudViewControllerFactoryMock()
)
// When
coordinator.openLearnHowToEnable()
// Then
wait(for: [openExpectation], timeout: 1)
}
func test_dismiss_givenInvoked_invokesNavigationDismissAnimated() {
// Given
let dismissExpectation = XCTestExpectation(description: "Navigation.dismiss is called.")
let navigation = Navigation.test(
dismissViewControllerProvider: { animated, _ in
// Then
XCTAssertTrue(animated)
dismissExpectation.fulfill()
}
)
let coordinator = EnableCloudCoordinator(
navigation: navigation,
enableCloudViewControllerFactory: EnableCloudViewControllerFactoryMock()
)
// When
coordinator.dismiss()
// Then
wait(for: [dismissExpectation], timeout: 1)
}
func test_dismiss_givenCompletion_invokesOnEnd() async {
// Given
let onEndExpectation = XCTestExpectation(description: "EnableCloudCoordinator.onEnd is called.")
let navigation = Navigation.test(
dismissViewControllerProvider: { _, completion in
completion?()
}
)
let coordinator = EnableCloudCoordinator(
navigation: navigation,
enableCloudViewControllerFactory: EnableCloudViewControllerFactoryMock()
)
coordinator.onEnd = { onEndExpectation.fulfill() }
// When
coordinator.dismiss()
// Then
await fulfillment(of: [onEndExpectation], timeout: 1)
}
}
================================================
FILE: Tests/EnableCloudPage/EnableCloudViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class EnableCloudViewModelTests: XCTestCase {
func test_items_givenInit_yieldsHeaderAndTwoFeatureRows() async throws {
// Given
let viewModel = EnableCloudViewModel(coordinator: EnableCloudCoordinatorMock())
// When
let items = try await firstValue(of: viewModel.items)
// Then
XCTAssertEqual(items.count, 3)
}
func test_rightNavigationItems_givenInit_yieldsXmarkSymbol() async throws {
// Given
let viewModel = EnableCloudViewModel(coordinator: EnableCloudCoordinatorMock())
// When
let items = try await firstValue(of: viewModel.rightNavigationItems)
// Then
XCTAssertEqual(items.count, 1)
guard case let .symbol(systemImageName, _) = items[0] else {
XCTFail("Expected .symbol")
return
}
XCTAssertEqual(systemImageName, "xmark")
}
func test_rightNavigationItem_givenTap_invokesCoordinatorDismiss() async throws {
// Given
let dismissExpectation = XCTestExpectation(description: "EnableCloudCoordinatorMock.dismiss is called.")
let coordinator = EnableCloudCoordinatorMock(
dismissProvider: { dismissExpectation.fulfill() }
)
let viewModel = EnableCloudViewModel(coordinator: coordinator)
// When
let items = try await firstValue(of: viewModel.rightNavigationItems)
guard case let .symbol(_, onAction) = items[0] else {
XCTFail("Expected .symbol")
return
}
onAction()
await Task.yield()
// Then
await fulfillment(of: [dismissExpectation], timeout: 1)
}
func test_actions_givenLearnHowToEnableTap_invokesCoordinatorOpenLearnHowToEnable() async throws {
// Given
let openExpectation = XCTestExpectation(
description: "EnableCloudCoordinatorMock.openLearnHowToEnable is called."
)
let coordinator = EnableCloudCoordinatorMock(
openLearnHowToEnableProvider: { openExpectation.fulfill() }
)
let viewModel = EnableCloudViewModel(coordinator: coordinator)
// When
XCTAssertEqual(viewModel.actions.count, 1)
viewModel.actions[0].action()
await Task.yield()
// Then
await fulfillment(of: [openExpectation], timeout: 1)
}
}
@MainActor
private func firstValue(
of sequence: S
) async throws -> S.Element where S.Element: Sendable {
var iterator = sequence.makeAsyncIterator()
guard let value = try await iterator.next() else {
throw NSError(domain: "EnableCloudViewModelTests", code: 0)
}
return value
}
================================================
FILE: Tests/EnableCloudPage/FeatureRowCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class FeatureRowCellViewModelTests: XCTestCase {
func test_init_storesSystemImageNameAndText() {
// When
let viewModel = FeatureRowCellViewModel(
systemImageName: "icloud",
text: "Sync across devices"
)
// Then
XCTAssertEqual(viewModel.systemImageName, "icloud")
XCTAssertEqual(viewModel.text, "Sync across devices")
}
}
================================================
FILE: Tests/FileService/LocalFileServiceTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import XCTest
@testable import Jottre
final class LocalFileServiceTests: XCTestCase {
private var rootDirectory: URL!
private var fileService: LocalFileService!
override func setUpWithError() throws {
try super.setUpWithError()
rootDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
fileService = LocalFileService(fileManager: FileManager.default)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: rootDirectory)
rootDirectory = nil
fileService = nil
try super.tearDownWithError()
}
func test_isEnabled_alwaysReturnsTrue() {
// Then
XCTAssertTrue(fileService.isEnabled())
}
func test_temporaryDirectory_returnsFileManagerTemporaryDirectory() {
// When
let url = fileService.temporaryDirectory()
// Then
XCTAssertEqual(url, FileManager.default.temporaryDirectory)
}
func test_writeFileThenReadFile_roundTripsBytes() throws {
// Given
let fileURL = rootDirectory.appendingPathComponent("note.jot")
let payload = Data("Hello".utf8)
// When
try fileService.writeFile(fileURL: fileURL, data: payload)
let readBack = try fileService.readFile(fileURL: fileURL)
// Then
XCTAssertEqual(readBack, payload)
}
func test_fileExists_givenWrittenFile_returnsTrue() throws {
// Given
let fileURL = rootDirectory.appendingPathComponent("note.jot")
try Data().write(to: fileURL)
// Then
XCTAssertTrue(fileService.fileExists(fileURL: fileURL))
}
func test_fileExists_givenMissingFile_returnsFalse() {
// Given
let fileURL = rootDirectory.appendingPathComponent("missing.jot")
// Then
XCTAssertFalse(fileService.fileExists(fileURL: fileURL))
}
func test_listContents_givenMixOfFiles_returnsAllURLs() throws {
// Given
let fileURL1 = rootDirectory.appendingPathComponent("a.jot")
let fileURL2 = rootDirectory.appendingPathComponent("b.txt")
try Data().write(to: fileURL1)
try Data().write(to: fileURL2)
// When
let urls = try fileService.listContents(directory: rootDirectory, properties: [])
// Then
let names = Set(urls.map(\.lastPathComponent))
XCTAssertEqual(names, ["a.jot", "b.txt"])
}
func test_moveFile_movesFileToDestinationAndDeletesOriginal() throws {
// Given
let originalURL = rootDirectory.appendingPathComponent("original.jot")
let destinationURL = rootDirectory.appendingPathComponent("destination.jot")
try Data("payload".utf8).write(to: originalURL)
// When
try fileService.moveFile(fileURL: originalURL, newFileURL: destinationURL)
// Then
XCTAssertFalse(FileManager.default.fileExists(atPath: originalURL.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: destinationURL.path))
XCTAssertEqual(try Data(contentsOf: destinationURL), Data("payload".utf8))
}
func test_removeFile_deletesFile() throws {
// Given
let fileURL = rootDirectory.appendingPathComponent("note.jot")
try Data().write(to: fileURL)
// When
try fileService.removeFile(fileURL: fileURL)
// Then
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
}
func test_duplicateFile_givenNoExistingDuplicate_writesPlainNamedCopy() throws {
// Given
let originalURL = rootDirectory.appendingPathComponent("note.jot")
try Data("payload".utf8).write(to: originalURL)
// When
let duplicatedURL = try fileService.duplicateFile(fileURL: originalURL)
// Then
XCTAssertEqual(duplicatedURL.pathExtension, "jot")
XCTAssertNotEqual(duplicatedURL, originalURL)
XCTAssertTrue(FileManager.default.fileExists(atPath: duplicatedURL.path))
XCTAssertEqual(try Data(contentsOf: duplicatedURL), Data("payload".utf8))
}
func test_duplicateFile_givenExistingDuplicate_writesIncrementedNamedCopy() throws {
// Given
let originalURL = rootDirectory.appendingPathComponent("note.jot")
try Data("payload".utf8).write(to: originalURL)
// When
let firstDuplicate = try fileService.duplicateFile(fileURL: originalURL)
let secondDuplicate = try fileService.duplicateFile(fileURL: originalURL)
// Then
XCTAssertNotEqual(firstDuplicate, secondDuplicate)
XCTAssertTrue(FileManager.default.fileExists(atPath: firstDuplicate.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: secondDuplicate.path))
}
func test_directoryChanges_emitsInitialEventOnSubscribe() async throws {
// Given
let stream = fileService.directoryChanges(directory: rootDirectory)
var iterator = stream.makeAsyncIterator()
// When
let first: Void? = await iterator.next()
// Then
XCTAssertNotNil(first)
}
}
================================================
FILE: Tests/FileService/UbiquitousInfoTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class UbiquitousInfoTests: XCTestCase {
func test_equality_givenSameValues_returnsEqual() {
// Given
let lhs = UbiquitousInfo(downloadStatus: .current, isDownloading: false)
let rhs = UbiquitousInfo(downloadStatus: .current, isDownloading: false)
// Then
XCTAssertEqual(lhs, rhs)
}
func test_equality_givenDifferentDownloadStatus_returnsNotEqual() {
// Given
let lhs = UbiquitousInfo(downloadStatus: .current, isDownloading: false)
let rhs = UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: false)
// Then
XCTAssertNotEqual(lhs, rhs)
}
func test_equality_givenDifferentIsDownloading_returnsNotEqual() {
// Given
let lhs = UbiquitousInfo(downloadStatus: .current, isDownloading: false)
let rhs = UbiquitousInfo(downloadStatus: .current, isDownloading: true)
// Then
XCTAssertNotEqual(lhs, rhs)
}
func test_equality_givenNilDownloadStatusOnBoth_returnsEqual() {
// Given
let lhs = UbiquitousInfo(downloadStatus: nil, isDownloading: true)
let rhs = UbiquitousInfo(downloadStatus: nil, isDownloading: true)
// Then
XCTAssertEqual(lhs, rhs)
}
}
================================================
FILE: Tests/Helpers/Navigation+test.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
extension Navigation {
static func test(
openURLProvider: @Sendable @escaping (_ url: URL) -> Void = { _ in },
openExternalURLProvider: @Sendable @escaping (_ url: URL) -> Void = { _ in },
openSceneProvider: @Sendable @escaping (_ url: URL) -> Void = { _ in },
presentViewControllerProvider:
@Sendable @escaping (_ viewController: UIViewController, _ animated: Bool) -> Void = { _, _ in },
dismissViewControllerProvider:
@Sendable @escaping (_ animated: Bool, _ completion: (@Sendable () -> Void)?) -> Void = { _, _ in },
popViewControllerProvider: @Sendable @escaping (_ animated: Bool) -> Void = { _ in },
getViewControllersProvider: @MainActor @escaping () -> [UIViewController] = { [] }
) -> Navigation {
Navigation(
openURLProvider: openURLProvider,
openExternalURLProvider: openExternalURLProvider,
openSceneProvider: openSceneProvider,
presentViewControllerProvider: presentViewControllerProvider,
dismissViewControllerProvider: dismissViewControllerProvider,
popViewControllerProvider: popViewControllerProvider,
getViewControllersProvider: getViewControllersProvider
)
}
}
================================================
FILE: Tests/Helpers/UIAlertAction+invoke.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
extension UIAlertAction {
func invokeHandler() {
typealias Handler = @convention(block) (UIAlertAction) -> Void
guard let block = value(forKey: "handler") else {
return
}
let handler = unsafeBitCast(block as AnyObject, to: Handler.self)
handler(self)
}
}
================================================
FILE: Tests/Helpers/URL+staticString.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
extension URL {
init(staticString: StaticString) {
guard let url = URL(string: "\(staticString)") else {
preconditionFailure("Invalid static URL string: \(staticString)")
}
self = url
}
}
================================================
FILE: Tests/Jot/JotFileServiceDocumentsDirectoryContentsTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import XCTest
@testable import Jottre
final class JotFileServiceDocumentsDirectoryContentsTests: XCTestCase {
private var localDocumentsDirectory: URL!
private var ubiquitousDocumentsDirectory: URL!
override func setUpWithError() throws {
try super.setUpWithError()
localDocumentsDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
ubiquitousDocumentsDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: localDocumentsDirectory,
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: ubiquitousDocumentsDirectory,
withIntermediateDirectories: true
)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: localDocumentsDirectory)
try? FileManager.default.removeItem(at: ubiquitousDocumentsDirectory)
localDocumentsDirectory = nil
ubiquitousDocumentsDirectory = nil
try super.tearDownWithError()
}
func test_documentsDirectoryContents_givenLocalAndUbiquitousJotFiles_yieldsCombinedContents() async throws {
// Given
let localFileURL = localDocumentsDirectory.appendingPathComponent("local.jot")
let ubiquitousFileURL = ubiquitousDocumentsDirectory.appendingPathComponent("cloud.jot")
try Data().write(to: localFileURL)
try Data().write(to: ubiquitousFileURL)
let jotFileService = makeJotFileService()
// When
var iterator = jotFileService.documentsDirectoryContents().makeAsyncIterator()
let infos = try await XCTUnwrapAsync(try await iterator.next())
// Then
let names = Set(infos.map(\.name))
XCTAssertEqual(names, ["local", "cloud"])
let cloudInfo = try XCTUnwrap(infos.first(where: { $0.name == "cloud" }))
XCTAssertNotNil(cloudInfo.ubiquitousInfo)
let localInfo = try XCTUnwrap(infos.first(where: { $0.name == "local" }))
XCTAssertNil(localInfo.ubiquitousInfo)
}
func test_documentsDirectoryContents_givenNonJotFileInDirectory_filtersItOut() async throws {
// Given
let jotFileURL = localDocumentsDirectory.appendingPathComponent("real.jot")
let textFileURL = localDocumentsDirectory.appendingPathComponent("ignored.txt")
try Data().write(to: jotFileURL)
try Data().write(to: textFileURL)
let jotFileService = makeJotFileService(includeUbiquitous: false)
// When
var iterator = jotFileService.documentsDirectoryContents().makeAsyncIterator()
let infos = try await XCTUnwrapAsync(try await iterator.next())
// Then
XCTAssertEqual(infos.map(\.name), ["real"])
}
func test_documentsDirectoryContents_givenLocalDirectoryUnavailable_yieldsOnlyUbiquitousContents() async throws {
// Given
let ubiquitousFileURL = ubiquitousDocumentsDirectory.appendingPathComponent("cloud.jot")
try Data().write(to: ubiquitousFileURL)
let localFileServiceMock = FileServiceMock(
documentsDirectoryProvider: { nil },
listContentsProvider: { _, _ in [] }
)
let ubiquitousFileServiceMock = FileServiceMock(
documentsDirectoryProvider: { [ubiquitousDocumentsDirectory] in ubiquitousDocumentsDirectory },
listContentsProvider: { directory, _ in
try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)
},
directoryChangesProvider: { _ in
AsyncStream { continuation in
continuation.yield(())
continuation.finish()
}
}
)
let jotFileService = JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: ubiquitousFileServiceMock
)
// When
var iterator = jotFileService.documentsDirectoryContents().makeAsyncIterator()
let infos = try await XCTUnwrapAsync(try await iterator.next())
// Then
XCTAssertEqual(infos.map(\.name), ["cloud"])
}
private func makeJotFileService(includeUbiquitous: Bool = true) -> JotFileService {
let localFileServiceMock = FileServiceMock(
documentsDirectoryProvider: { [localDocumentsDirectory] in localDocumentsDirectory },
listContentsProvider: { directory, _ in
try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)
},
directoryChangesProvider: { _ in
AsyncStream { continuation in
continuation.yield(())
continuation.finish()
}
}
)
let ubiquitousFileServiceMock: FileServiceMock
if includeUbiquitous {
ubiquitousFileServiceMock = FileServiceMock(
documentsDirectoryProvider: { [ubiquitousDocumentsDirectory] in ubiquitousDocumentsDirectory },
listContentsProvider: { directory, _ in
try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)
},
directoryChangesProvider: { _ in
AsyncStream { continuation in
continuation.yield(())
continuation.finish()
}
}
)
} else {
ubiquitousFileServiceMock = FileServiceMock(
documentsDirectoryProvider: { nil },
listContentsProvider: { _, _ in [] }
)
}
return JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: ubiquitousFileServiceMock
)
}
}
private func XCTUnwrapAsync(
_ expression: @autoclosure () async throws -> T?,
file: StaticString = #filePath,
line: UInt = #line
) async throws -> T {
let value = try await expression()
return try XCTUnwrap(value, file: file, line: line)
}
================================================
FILE: Tests/Jot/JotFileServiceTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class JotFileServiceTests: XCTestCase {
func test_write_givenLocalJotFile_writesEncodedDataToLocalFileService() async throws {
// Given
let writeFileProviderExpectation = XCTestExpectation(
description: "FileServiceMock.writeFileProvider is called."
)
let expectedFileURL = URL(staticString: "file:///tmp/note.jot")
let jot = Jot.makeEmpty()
let jotFile = JotFile(
info: JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
),
jot: jot
)
let expectedData = try PropertyListEncoder().encode(jot)
let localFileServiceMock = FileServiceMock(
writeFileProvider: { receivedFileURL, receivedData in
// Then
XCTAssertEqual(receivedFileURL, expectedFileURL)
XCTAssertEqual(receivedData, expectedData)
writeFileProviderExpectation.fulfill()
}
)
let ubiquitousFileServiceMock = FileServiceMock(
writeFileProvider: { _, _ in
XCTFail("Ubiquitous file service should not be used for local jot files.")
}
)
let jotFileService = JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: ubiquitousFileServiceMock
)
// When
try jotFileService.write(jotFile: jotFile)
// Then
await fulfillment(of: [writeFileProviderExpectation], timeout: 0.2)
}
func test_write_givenUbiquitousJotFile_writesEncodedDataToUbiquitousFileService() async throws {
// Given
let writeFileProviderExpectation = XCTestExpectation(description: "Ubiquitous writeFileProvider is called.")
let expectedFileURL = URL(staticString: "file:///cloud/note.jot")
let jot = Jot.makeEmpty()
let jotFile = JotFile(
info: JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(
downloadStatus: .current,
isDownloading: false
)
),
jot: jot
)
let localFileServiceMock = FileServiceMock(
writeFileProvider: { _, _ in
XCTFail("Local file service should not be used for ubiquitous jot files.")
}
)
let ubiquitousFileServiceMock = FileServiceMock(
writeFileProvider: { receivedFileURL, _ in
// Then
XCTAssertEqual(receivedFileURL, expectedFileURL)
writeFileProviderExpectation.fulfill()
}
)
let jotFileService = JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: ubiquitousFileServiceMock
)
// When
try jotFileService.write(jotFile: jotFile)
// Then
await fulfillment(of: [writeFileProviderExpectation], timeout: 0.2)
}
func test_readJotFile_givenLocalJotFileInfo_decodesDataFromLocalFileService() async throws {
// Given
let readFileProviderExpectation = XCTestExpectation(description: "Local readFileProvider is called.")
let expectedFileURL = URL(staticString: "file:///tmp/note.jot")
let expectedJot = Jot.makeEmpty()
let encodedData = try PropertyListEncoder().encode(expectedJot)
let localFileServiceMock = FileServiceMock(
readFileProvider: { receivedFileURL in
// Then
XCTAssertEqual(receivedFileURL, expectedFileURL)
readFileProviderExpectation.fulfill()
return encodedData
}
)
let jotFileService = JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: FileServiceMock()
)
// When
let jotFile = try jotFileService.readJotFile(
jotFileInfo: JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
)
// Then
XCTAssertEqual(jotFile.jot.drawing, expectedJot.drawing)
XCTAssertEqual(jotFile.jot.width, expectedJot.width)
XCTAssertEqual(jotFile.jot.version, expectedJot.version)
await fulfillment(of: [readFileProviderExpectation], timeout: 0.2)
}
func test_remove_givenLocalJotFileInfo_callsRemoveOnLocalFileService() async throws {
// Given
let removeFileProviderExpectation = XCTestExpectation(description: "Local removeFileProvider is called.")
let expectedFileURL = URL(staticString: "file:///tmp/note.jot")
let localFileServiceMock = FileServiceMock(
removeFileProvider: { receivedFileURL in
// Then
XCTAssertEqual(receivedFileURL, expectedFileURL)
removeFileProviderExpectation.fulfill()
}
)
let jotFileService = JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: FileServiceMock()
)
// When
try jotFileService.remove(
jotFileInfo: JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
)
// Then
await fulfillment(of: [removeFileProviderExpectation], timeout: 0.2)
}
func test_rename_givenLocalJotFileInfo_movesToNewURLAndReturnsUpdatedInfo() async throws {
// Given
let moveFileProviderExpectation = XCTestExpectation(description: "Local moveFileProvider is called.")
let originalFileURL = URL(staticString: "file:///tmp/old.jot")
let expectedNewFileURL = URL(staticString: "file:///tmp/new.jot")
let localFileServiceMock = FileServiceMock(
moveFileProvider: { receivedFileURL, receivedNewFileURL in
// Then
XCTAssertEqual(receivedFileURL, originalFileURL)
XCTAssertEqual(receivedNewFileURL, expectedNewFileURL)
moveFileProviderExpectation.fulfill()
}
)
let jotFileService = JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: FileServiceMock()
)
// When
let renamed = try jotFileService.rename(
jotFileInfo: JotFile.Info(
url: originalFileURL,
name: "old",
modificationDate: nil,
ubiquitousInfo: nil
),
newName: "new"
)
// Then
XCTAssertEqual(renamed.url, expectedNewFileURL)
XCTAssertEqual(renamed.name, "new")
await fulfillment(of: [moveFileProviderExpectation], timeout: 0.2)
}
func test_duplicate_givenLocalJotFileInfo_returnsDuplicatedInfo() async throws {
// Given
let duplicateFileProviderExpectation = XCTestExpectation(description: "Local duplicateFileProvider is called.")
let originalFileURL = URL(staticString: "file:///tmp/note.jot")
let duplicatedFileURL = URL(staticString: "file:///tmp/note-1.jot")
let localFileServiceMock = FileServiceMock(
duplicateFileProvider: { receivedFileURL in
// Then
XCTAssertEqual(receivedFileURL, originalFileURL)
duplicateFileProviderExpectation.fulfill()
return duplicatedFileURL
}
)
let jotFileService = JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: FileServiceMock()
)
// When
let duplicated = try jotFileService.duplicate(
jotFileInfo: JotFile.Info(
url: originalFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
)
// Then
XCTAssertEqual(duplicated.url, duplicatedFileURL)
XCTAssertEqual(duplicated.name, "note-1")
await fulfillment(of: [duplicateFileProviderExpectation], timeout: 0.2)
}
func test_move_givenShouldBecomeUbiquitous_movesIntoUbiquitousDocumentsDirectory() async throws {
// Given
let moveFileProviderExpectation = XCTestExpectation(description: "Ubiquitous moveFileProvider is called.")
let originalFileURL = URL(staticString: "file:///tmp/note.jot")
let ubiquitousDocumentsDirectory = URL(staticString: "file:///cloud/Documents/")
let expectedNewFileURL = ubiquitousDocumentsDirectory.appendingPathComponent("note.jot", isDirectory: false)
let ubiquitousFileServiceMock = FileServiceMock(
documentsDirectoryProvider: { ubiquitousDocumentsDirectory },
moveFileProvider: { receivedFileURL, receivedNewFileURL in
// Then
XCTAssertEqual(receivedFileURL, originalFileURL)
XCTAssertEqual(receivedNewFileURL, expectedNewFileURL)
moveFileProviderExpectation.fulfill()
}
)
let jotFileService = JotFileService(
localFileService: FileServiceMock(),
ubiquitousFileService: ubiquitousFileServiceMock
)
// When
try await jotFileService.move(
jotFileInfo: JotFile.Info(
url: originalFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
),
shouldBecomeUbiquitous: true
)
// Then
await fulfillment(of: [moveFileProviderExpectation], timeout: 0.2)
}
func test_readJotFile_givenUbiquitousJotFileInfo_decodesDataFromUbiquitousFileService() async throws {
// Given
let readFileProviderExpectation = XCTestExpectation(description: "Ubiquitous readFileProvider is called.")
let expectedFileURL = URL(staticString: "file:///cloud/note.jot")
let expectedJot = Jot.makeEmpty()
let encodedData = try PropertyListEncoder().encode(expectedJot)
let ubiquitousFileServiceMock = FileServiceMock(
readFileProvider: { receivedFileURL in
// Then
XCTAssertEqual(receivedFileURL, expectedFileURL)
readFileProviderExpectation.fulfill()
return encodedData
}
)
let jotFileService = JotFileService(
localFileService: FileServiceMock(
readFileProvider: { _ in
XCTFail("Local file service should not be used for ubiquitous jot files.")
return Data()
}
),
ubiquitousFileService: ubiquitousFileServiceMock
)
// When
_ = try jotFileService.readJotFile(
jotFileInfo: JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
)
)
// Then
await fulfillment(of: [readFileProviderExpectation], timeout: 0.2)
}
func test_remove_givenUbiquitousJotFileInfo_callsRemoveOnUbiquitousFileService() async throws {
// Given
let removeFileProviderExpectation = XCTestExpectation(description: "Ubiquitous removeFileProvider is called.")
let expectedFileURL = URL(staticString: "file:///cloud/note.jot")
let ubiquitousFileServiceMock = FileServiceMock(
removeFileProvider: { receivedFileURL in
// Then
XCTAssertEqual(receivedFileURL, expectedFileURL)
removeFileProviderExpectation.fulfill()
}
)
let jotFileService = JotFileService(
localFileService: FileServiceMock(
removeFileProvider: { _ in
XCTFail("Local file service should not be used for ubiquitous jot files.")
}
),
ubiquitousFileService: ubiquitousFileServiceMock
)
// When
try jotFileService.remove(
jotFileInfo: JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
)
)
// Then
await fulfillment(of: [removeFileProviderExpectation], timeout: 0.2)
}
func test_rename_givenUbiquitousJotFileInfo_movesViaUbiquitousFileService() async throws {
// Given
let moveFileProviderExpectation = XCTestExpectation(description: "Ubiquitous moveFileProvider is called.")
let originalFileURL = URL(staticString: "file:///cloud/old.jot")
let expectedNewFileURL = URL(staticString: "file:///cloud/new.jot")
let ubiquitousFileServiceMock = FileServiceMock(
moveFileProvider: { receivedFileURL, receivedNewFileURL in
// Then
XCTAssertEqual(receivedFileURL, originalFileURL)
XCTAssertEqual(receivedNewFileURL, expectedNewFileURL)
moveFileProviderExpectation.fulfill()
}
)
let jotFileService = JotFileService(
localFileService: FileServiceMock(),
ubiquitousFileService: ubiquitousFileServiceMock
)
// When
let renamed = try jotFileService.rename(
jotFileInfo: JotFile.Info(
url: originalFileURL,
name: "old",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
),
newName: "new"
)
// Then
XCTAssertEqual(renamed.url, expectedNewFileURL)
XCTAssertEqual(renamed.name, "new")
await fulfillment(of: [moveFileProviderExpectation], timeout: 0.2)
}
func test_duplicate_givenUbiquitousJotFileInfo_returnsDuplicatedInfoFromUbiquitousFileService() async throws {
// Given
let duplicateFileProviderExpectation = XCTestExpectation(
description: "Ubiquitous duplicateFileProvider is called."
)
let originalFileURL = URL(staticString: "file:///cloud/note.jot")
let duplicatedFileURL = URL(staticString: "file:///cloud/note-1.jot")
let originalUbiquitousInfo = UbiquitousInfo(downloadStatus: .current, isDownloading: false)
let ubiquitousFileServiceMock = FileServiceMock(
duplicateFileProvider: { receivedFileURL in
// Then
XCTAssertEqual(receivedFileURL, originalFileURL)
duplicateFileProviderExpectation.fulfill()
return duplicatedFileURL
}
)
let jotFileService = JotFileService(
localFileService: FileServiceMock(),
ubiquitousFileService: ubiquitousFileServiceMock
)
// When
let duplicated = try jotFileService.duplicate(
jotFileInfo: JotFile.Info(
url: originalFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: originalUbiquitousInfo
)
)
// Then
XCTAssertEqual(duplicated.url, duplicatedFileURL)
XCTAssertEqual(duplicated.name, "note-1")
XCTAssertEqual(duplicated.ubiquitousInfo, originalUbiquitousInfo)
await fulfillment(of: [duplicateFileProviderExpectation], timeout: 0.2)
}
func test_move_givenDocumentsDirectoryUnresolved_throwsCouldNotResolveFailure() async {
// Given
let localFileServiceMock = FileServiceMock(
documentsDirectoryProvider: { nil }
)
let jotFileService = JotFileService(
localFileService: localFileServiceMock,
ubiquitousFileService: FileServiceMock()
)
// When / Then
do {
try await jotFileService.move(
jotFileInfo: JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
),
shouldBecomeUbiquitous: false
)
XCTFail("Expected JotFileService.Failure.couldNotResolveDocumentsDirectory.")
} catch JotFileService.Failure.couldNotResolveDocumentsDirectory {
// Expected
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}
================================================
FILE: Tests/Jot/JotFileTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class JotFileTests: XCTestCase {
func test_infoInit_givenURLWithJotExtension_succeedsAndDerivesNameFromURL() throws {
// Given
let url = URL(staticString: "file:///tmp/note.jot")
// When
let info = try XCTUnwrap(
JotFile.Info(
url: url,
modificationDate: nil,
ubiquitousInfo: nil
)
)
// Then
XCTAssertEqual(info.name, "note")
XCTAssertEqual(info.url, url)
}
func test_infoInit_givenURLWithNonJotExtension_returnsNil() {
// Given
let url = URL(staticString: "file:///tmp/note.txt")
// When
let info = JotFile.Info(
url: url,
modificationDate: nil,
ubiquitousInfo: nil
)
// Then
XCTAssertNil(info)
}
func test_makeEmptyJot_returnsVersionThreeWithDefaultWidth() {
// When
let jot = Jot.makeEmpty()
// Then
XCTAssertEqual(jot.version, 3)
XCTAssertEqual(jot.width, 1200)
XCTAssertFalse(jot.drawing.isEmpty)
}
}
================================================
FILE: Tests/JotConflictPage/JotConflictBusinessModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class JotConflictBusinessModelTests: XCTestCase {
func test_init_givenSavingComputerName_usesAsLastEditedDateString() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let version = JotFileVersion(
localizedNameOfSavingComputer: "Anton's Mac",
info: info
)
// When
let model = JotConflictBusinessModel(
name: "label",
jotFileInfo: info,
jotFileVersion: version
)
// Then
XCTAssertEqual(model.name, "label")
XCTAssertEqual(model.lastEditedDateString, "Anton's Mac")
XCTAssertEqual(model.jotFileInfo, info)
}
func test_init_givenNilSavingComputerName_returnsNotApplicableString() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let version = JotFileVersion(
localizedNameOfSavingComputer: nil,
info: info
)
// When
let model = JotConflictBusinessModel(
name: "label",
jotFileInfo: info,
jotFileVersion: version
)
// Then
XCTAssertEqual(model.lastEditedDateString, "n/a")
}
func test_toJotFileVersion_returnsOriginalVersion() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let version = JotFileVersion(localizedNameOfSavingComputer: nil, info: info)
// When
let model = JotConflictBusinessModel(
name: "label",
jotFileInfo: info,
jotFileVersion: version
)
// Then
XCTAssertEqual(model.toJotFileVersion(), version)
}
}
================================================
FILE: Tests/JotConflictPage/JotConflictCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class JotConflictCellViewModelTests: XCTestCase {
func test_init_storesNameAndInfoTextFromBusinessModel() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let businessModel = JotConflictBusinessModel(
name: "note",
jotFileInfo: jotFileInfo,
jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: "Mac", info: jotFileInfo)
)
// When
let viewModel = JotConflictCellViewModel(
jotConflict: businessModel,
repository: JotConflictRepositoryMock()
)
// Then
XCTAssertEqual(viewModel.name, "note")
XCTAssertEqual(viewModel.infoText, "Mac")
}
func test_init_givenJotFileVersionWithoutSavingComputer_setsInfoTextToNa() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let businessModel = JotConflictBusinessModel(
name: "note",
jotFileInfo: jotFileInfo,
jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: nil, info: jotFileInfo)
)
// When
let viewModel = JotConflictCellViewModel(
jotConflict: businessModel,
repository: JotConflictRepositoryMock()
)
// Then
XCTAssertEqual(viewModel.infoText, "n/a")
}
func test_getPreviewImage_forwardsJotFileInfoAndVersionToRepository() async throws {
// Given
let getPreviewImageExpectation = XCTestExpectation(
description: "JotConflictRepositoryMock.getPreviewImageProvider is called."
)
let expectedImage = UIImage()
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let jotFileVersion = JotFileVersion(localizedNameOfSavingComputer: "Mac", info: jotFileInfo)
let businessModel = JotConflictBusinessModel(
name: "note",
jotFileInfo: jotFileInfo,
jotFileVersion: jotFileVersion
)
let repositoryMock = JotConflictRepositoryMock(
getPreviewImageProvider: { receivedInfo, receivedVersion, receivedStyle, receivedScale in
// Then
XCTAssertEqual(receivedInfo, jotFileInfo)
XCTAssertEqual(receivedVersion, jotFileVersion)
XCTAssertEqual(receivedStyle, .dark)
XCTAssertEqual(receivedScale, 3.0)
getPreviewImageExpectation.fulfill()
return expectedImage
}
)
let viewModel = JotConflictCellViewModel(
jotConflict: businessModel,
repository: repositoryMock
)
// When
let image = await viewModel.getPreviewImage(userInterfaceStyle: .dark, displayScale: 3.0)
// Then
XCTAssertIdentical(image, expectedImage)
await fulfillment(of: [getPreviewImageExpectation], timeout: 0.2)
}
func test_handleAction_givenTap_doesNothing() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let viewModel = JotConflictCellViewModel(
jotConflict: JotConflictBusinessModel(
name: "note",
jotFileInfo: jotFileInfo,
jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: nil, info: jotFileInfo)
),
repository: JotConflictRepositoryMock()
)
// When
viewModel.handle(action: .tap)
// Then
XCTAssertEqual(viewModel.name, "note")
}
}
================================================
FILE: Tests/JotConflictPage/JotConflictCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class JotConflictCoordinatorTests: XCTestCase {
func test_start_givenInvoked_presentsModalNavigationController() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let madeViewController = UIViewController()
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
let navigationController = viewController as? UINavigationController
XCTAssertEqual(navigationController?.viewControllers.first, madeViewController)
XCTAssertTrue(madeViewController.isModalInPresentation)
XCTAssertFalse(navigationController?.navigationBar.prefersLargeTitles ?? true)
presentExpectation.fulfill()
}
}
)
let coordinator = JotConflictCoordinator(
jotFileInfo: info,
jotFileVersions: [JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info)],
repository: JotConflictRepositoryMock(),
navigation: navigation,
jotConflictViewControllerFactory: JotConflictViewControllerFactoryMock(
makeProvider: { _ in madeViewController }
),
onResult: { _ in }
)
// When
coordinator.start()
// Then
wait(for: [presentExpectation], timeout: 1)
}
func test_dismiss_givenCompletion_invokesNavigationDismissAndOnEnd() async {
// Given
let completionExpectation = XCTestExpectation(description: "Completion is invoked.")
let onEndExpectation = XCTestExpectation(description: "JotConflictCoordinator.onEnd is invoked.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
dismissViewControllerProvider: { _, completion in
completion?()
}
)
let coordinator = JotConflictCoordinator(
jotFileInfo: info,
jotFileVersions: [],
repository: JotConflictRepositoryMock(),
navigation: navigation,
jotConflictViewControllerFactory: JotConflictViewControllerFactoryMock(),
onResult: { _ in }
)
coordinator.onEnd = { onEndExpectation.fulfill() }
// When
coordinator.dismiss(completion: { completionExpectation.fulfill() })
// Then
await fulfillment(of: [completionExpectation, onEndExpectation], timeout: 1)
}
func test_showInfoAlert_givenInvoked_presentsAlertController() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
XCTAssertTrue(viewController is UIAlertController)
presentExpectation.fulfill()
}
}
)
let coordinator = JotConflictCoordinator(
jotFileInfo: info,
jotFileVersions: [],
repository: JotConflictRepositoryMock(),
navigation: navigation,
jotConflictViewControllerFactory: JotConflictViewControllerFactoryMock(),
onResult: { _ in }
)
// When
coordinator.showInfoAlert(title: "title", message: "message")
// Then
wait(for: [presentExpectation], timeout: 1)
}
}
================================================
FILE: Tests/JotConflictPage/JotConflictRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
final class JotConflictRepositoryTests: XCTestCase {
func test_resolveVersionConflicts_forwardsToJotFileConflictService() async throws {
// Given
let resolveVersionConflictsExpectation = XCTestExpectation(
description: "JotFileConflictServiceMock.resolveVersionConflictsProvider is called."
)
let inputInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let inputVersions = [
JotFileVersion(localizedNameOfSavingComputer: "Mac", info: inputInfo),
JotFileVersion(localizedNameOfSavingComputer: "iPad", info: inputInfo),
]
let jotFileConflictServiceMock = JotFileConflictServiceMock(
resolveVersionConflictsProvider: { receivedInfo, receivedVersions in
// Then
XCTAssertEqual(receivedInfo, inputInfo)
XCTAssertEqual(receivedVersions, inputVersions)
resolveVersionConflictsExpectation.fulfill()
}
)
let repository = JotConflictRepository(
jotFileConflictService: jotFileConflictServiceMock,
jotFilePreviewImageService: JotFilePreviewImageServiceMock(),
logger: LoggerMock()
)
// When
try repository.resolveVersionConflicts(jotFileInfo: inputInfo, resolvedVersions: inputVersions)
// Then
await fulfillment(of: [resolveVersionConflictsExpectation], timeout: 0.2)
}
func test_getPreviewImage_givenCopyVersionToTemporaryReturnsNil_usesOriginalInfo() async throws {
// Given
let getPreviewImageDataExpectation = XCTestExpectation(
description: "JotFilePreviewImageServiceMock.getPreviewImageDataProvider is called with original info."
)
let originalInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let imageData = try XCTUnwrap(UIImage(systemName: "doc")?.pngData())
let jotFileConflictServiceMock = JotFileConflictServiceMock(
copyVersionToTemporaryProvider: { _, _ in nil }
)
let jotFilePreviewImageServiceMock = JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { receivedInfo, receivedStyle, receivedScale in
// Then
XCTAssertEqual(receivedInfo, originalInfo)
XCTAssertEqual(receivedStyle, .light)
XCTAssertEqual(receivedScale, 2.0)
getPreviewImageDataExpectation.fulfill()
return imageData
}
)
let repository = JotConflictRepository(
jotFileConflictService: jotFileConflictServiceMock,
jotFilePreviewImageService: jotFilePreviewImageServiceMock,
logger: LoggerMock()
)
// When
let image = await repository.getPreviewImage(
jotFileInfo: originalInfo,
jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: "Mac", info: originalInfo),
userInterfaceStyle: .light,
displayScale: 2.0
)
// Then
XCTAssertNotNil(image)
await fulfillment(of: [getPreviewImageDataExpectation], timeout: 0.2)
}
func test_getPreviewImage_givenCopyVersionToTemporaryReturnsInfo_usesTemporaryInfoAndCleansUp() async throws {
// Given
let getPreviewImageDataExpectation = XCTestExpectation(
description: "JotFilePreviewImageServiceMock.getPreviewImageDataProvider is called with temporary info."
)
let originalInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let temporaryURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jot")
try Data().write(to: temporaryURL)
let temporaryInfo = JotFile.Info(
url: temporaryURL,
name: "tmp",
modificationDate: nil,
ubiquitousInfo: nil
)
let imageData = try XCTUnwrap(UIImage(systemName: "doc")?.pngData())
let jotFileConflictServiceMock = JotFileConflictServiceMock(
copyVersionToTemporaryProvider: { _, _ in temporaryInfo }
)
let jotFilePreviewImageServiceMock = JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { receivedInfo, _, _ in
// Then
XCTAssertEqual(receivedInfo, temporaryInfo)
getPreviewImageDataExpectation.fulfill()
return imageData
}
)
let repository = JotConflictRepository(
jotFileConflictService: jotFileConflictServiceMock,
jotFilePreviewImageService: jotFilePreviewImageServiceMock,
logger: LoggerMock()
)
// When
let image = await repository.getPreviewImage(
jotFileInfo: originalInfo,
jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: "Mac", info: originalInfo),
userInterfaceStyle: .light,
displayScale: 2.0
)
// Then
XCTAssertNotNil(image)
XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryURL.path))
await fulfillment(of: [getPreviewImageDataExpectation], timeout: 0.2)
}
func test_getPreviewImage_givenPreviewImageServiceThrows_returnsNilAndLogsError() async throws {
// Given
let errorExpectation = XCTestExpectation(description: "LoggerMock.errorProvider is called.")
let originalInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let repository = JotConflictRepository(
jotFileConflictService: JotFileConflictServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { _, _, _ in
throw NSError(domain: "test", code: 0)
}
),
logger: LoggerMock(
errorProvider: { message in
if message.contains("Failed to load conflict preview image") {
errorExpectation.fulfill()
}
}
)
)
// When
let image = await repository.getPreviewImage(
jotFileInfo: originalInfo,
jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: "Mac", info: originalInfo),
userInterfaceStyle: .light,
displayScale: 2.0
)
// Then
XCTAssertNil(image)
await fulfillment(of: [errorExpectation], timeout: 0.2)
}
}
================================================
FILE: Tests/JotConflictPage/JotConflictViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class JotConflictViewModelTests: XCTestCase {
func test_items_givenOneVersion_yieldsHeaderAndDeviceVersionAndProvidedVersion() async throws {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let version = JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info)
let viewModel = JotConflictViewModel(
jotFileInfo: info,
jotFileVersions: [version],
repository: JotConflictRepositoryMock(),
coordinator: JotConflictCoordinatorMock(),
onResult: { _ in }
)
// When
let items = try await firstValue(of: viewModel.items)
// Then
XCTAssertEqual(items.count, 3)
}
func test_actions_givenTwoVersions_yieldsThreeActions() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let viewModel = JotConflictViewModel(
jotFileInfo: info,
jotFileVersions: [
JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info),
JotFileVersion(localizedNameOfSavingComputer: "iPad", info: info),
],
repository: JotConflictRepositoryMock(),
coordinator: JotConflictCoordinatorMock(),
onResult: { _ in }
)
// Then: 1 device version + 2 provided versions + keep-all = 4
XCTAssertEqual(viewModel.actions.count, 4)
}
func test_actions_givenKeepAllTap_resolvesAllAndDismissesWithKeepAllResult() {
// Given
let resolveExpectation = XCTestExpectation(description: "Repository.resolveVersionConflicts is called.")
let dismissExpectation = XCTestExpectation(description: "Coordinator.dismiss is called.")
let onResultExpectation = XCTestExpectation(description: "onResult is called with .keepAll.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = JotConflictCoordinatorMock(
dismissProvider: { completion in
dismissExpectation.fulfill()
completion()
}
)
let viewModel = JotConflictViewModel(
jotFileInfo: info,
jotFileVersions: [
JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info)
],
repository: JotConflictRepositoryMock(
resolveVersionConflictsProvider: { _, resolved in
XCTAssertEqual(resolved.count, 2)
resolveExpectation.fulfill()
}
),
coordinator: coordinator,
onResult: { result in
if case .keepAll = result {
onResultExpectation.fulfill()
}
}
)
// When (last action is keepAll)
viewModel.actions.last?.action()
// Then
wait(for: [resolveExpectation, dismissExpectation, onResultExpectation], timeout: 1)
}
func test_actions_givenKeepVersionThrows_invokesShowInfoAlert() async {
// Given
let alertExpectation = XCTestExpectation(description: "Coordinator.showInfoAlert is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = JotConflictCoordinatorMock(
showInfoAlertProvider: { _, _ in alertExpectation.fulfill() }
)
let viewModel = JotConflictViewModel(
jotFileInfo: info,
jotFileVersions: [
JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info)
],
repository: JotConflictRepositoryMock(
resolveVersionConflictsProvider: { _, _ in
throw NSError(domain: "test", code: 0)
}
),
coordinator: coordinator,
onResult: { _ in }
)
// When (first action is keep version)
viewModel.actions.first?.action()
// Then
await fulfillment(of: [alertExpectation], timeout: 1)
}
}
@MainActor
private func firstValue(
of sequence: S
) async throws -> S.Element where S.Element: Sendable {
var iterator = sequence.makeAsyncIterator()
guard let value = try await iterator.next() else {
throw NSError(domain: "JotConflictViewModelTests", code: 0)
}
return value
}
================================================
FILE: Tests/JotConflictPage/JotFileConflictServiceTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class JotFileConflictServiceTests: XCTestCase {
func test_getConfictingVersions_givenNilFromUnderlyingService_returnsNil() {
// Given
let service = JotFileConflictService(
fileConflictService: FileConflictServiceMock(
getConflictingVersionsProvider: { _ in nil }
)
)
// When
let result = service.getConfictingVersions(jotFileInfo: makeJotFileInfo())
// Then
XCTAssertNil(result)
}
func test_getConfictingVersions_givenEmptyArrayFromUnderlyingService_returnsNil() {
// Given
let service = JotFileConflictService(
fileConflictService: FileConflictServiceMock(
getConflictingVersionsProvider: { _ in [] }
)
)
// When
let result = service.getConfictingVersions(jotFileInfo: makeJotFileInfo())
// Then
XCTAssertNil(result)
}
func test_resolveVersionConflicts_forwardsURLsToUnderlyingService() async throws {
// Given
let resolveVersionConflictsExpectation = XCTestExpectation(
description: "FileConflictServiceMock.resolveVersionConflictsProvider is called."
)
let inputInfo = makeJotFileInfo()
let versionURLA = URL(staticString: "file:///tmp/version-a.jot")
let versionURLB = URL(staticString: "file:///tmp/version-b.jot")
let versionInfos = [
JotFileVersion(
localizedNameOfSavingComputer: "A",
info: JotFile.Info(url: versionURLA, name: "a", modificationDate: nil, ubiquitousInfo: nil)
),
JotFileVersion(
localizedNameOfSavingComputer: "B",
info: JotFile.Info(url: versionURLB, name: "b", modificationDate: nil, ubiquitousInfo: nil)
),
]
let fileConflictServiceMock = FileConflictServiceMock(
resolveVersionConflictsProvider: { receivedFileURL, receivedResolvedVersions in
// Then
XCTAssertEqual(receivedFileURL, inputInfo.url)
XCTAssertEqual(receivedResolvedVersions, [versionURLA, versionURLB])
resolveVersionConflictsExpectation.fulfill()
}
)
let service = JotFileConflictService(fileConflictService: fileConflictServiceMock)
// When
try service.resolveVersionConflicts(jotFileInfo: inputInfo, resolvedVersions: versionInfos)
// Then
await fulfillment(of: [resolveVersionConflictsExpectation], timeout: 0.2)
}
func test_copyVersionToTemporary_givenUnderlyingReturnsURL_returnsInfoWithTemporaryURLAndOriginalMetadata() throws {
// Given
let temporaryURL = URL(staticString: "file:///tmp/copy.jot")
let fileConflictServiceMock = FileConflictServiceMock(
copyVersionToTemporaryProvider: { _, _ in temporaryURL }
)
let service = JotFileConflictService(fileConflictService: fileConflictServiceMock)
let modificationDate = Date(timeIntervalSince1970: 1_000)
let versionInfo = JotFile.Info(
url: URL(staticString: "file:///cloud/version.jot"),
name: "version",
modificationDate: modificationDate,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
)
let jotFileVersion = JotFileVersion(localizedNameOfSavingComputer: "Mac", info: versionInfo)
// When
let result = try service.copyVersionToTemporary(
jotFileInfo: makeJotFileInfo(),
jotFileVersion: jotFileVersion
)
// Then
let unwrapped = try XCTUnwrap(result)
XCTAssertEqual(unwrapped.url, temporaryURL)
XCTAssertEqual(unwrapped.name, versionInfo.name)
XCTAssertEqual(unwrapped.modificationDate, modificationDate)
XCTAssertEqual(unwrapped.ubiquitousInfo, versionInfo.ubiquitousInfo)
}
func test_copyVersionToTemporary_givenUnderlyingReturnsNil_returnsNil() throws {
// Given
let service = JotFileConflictService(
fileConflictService: FileConflictServiceMock(copyVersionToTemporaryProvider: { _, _ in nil })
)
let jotFileVersion = JotFileVersion(
localizedNameOfSavingComputer: nil,
info: makeJotFileInfo()
)
// When
let result = try service.copyVersionToTemporary(
jotFileInfo: makeJotFileInfo(),
jotFileVersion: jotFileVersion
)
// Then
XCTAssertNil(result)
}
private func makeJotFileInfo() -> JotFile.Info {
JotFile.Info(
url: URL(staticString: "file:///cloud/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
)
}
}
================================================
FILE: Tests/JotFilePreview/CachedJotFilePreviewImageServiceTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class CachedJotFilePreviewImageServiceTests: XCTestCase {
private let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: Date(timeIntervalSince1970: 1_000),
ubiquitousInfo: nil
)
func test_getPreviewImageData_givenColdCache_callsUnderlyingServiceAndReturnsItsData() async throws {
// Given
let underlyingExpectation = XCTestExpectation(description: "Underlying service is called.")
let expectedData = Data([1, 2, 3])
let underlying = JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { _, _, _ in
underlyingExpectation.fulfill()
return expectedData
}
)
let service = CachedJotFilePreviewImageService(
localFileService: FileServiceMock(
readFileProvider: { _ in throw NSError(domain: "noDiskCache", code: 0) }
),
jotFilePreviewImageService: underlying
)
// When
let data = try await service.getPreviewImageData(
jotFileInfo: info,
userInterfaceStyle: .light,
displayScale: 2.0
)
// Then
XCTAssertEqual(data, expectedData)
await fulfillment(of: [underlyingExpectation], timeout: 0.5)
}
func test_getPreviewImageData_givenSecondCallWithSameKey_servesFromMemoryCache() async throws {
// Given
let underlyingCallCount = LockIsolated(0)
let expectedData = Data([9, 8, 7])
let underlying = JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { _, _, _ in
underlyingCallCount.withValue { $0 += 1 }
return expectedData
}
)
let service = CachedJotFilePreviewImageService(
localFileService: FileServiceMock(
readFileProvider: { _ in throw NSError(domain: "noDiskCache", code: 0) }
),
jotFilePreviewImageService: underlying
)
// When
_ = try await service.getPreviewImageData(jotFileInfo: info, userInterfaceStyle: .light, displayScale: 2.0)
let second = try await service.getPreviewImageData(
jotFileInfo: info,
userInterfaceStyle: .light,
displayScale: 2.0
)
// Then
XCTAssertEqual(second, expectedData)
XCTAssertEqual(underlyingCallCount.value, 1)
}
func test_getPreviewImageData_givenColdCache_writesThroughToDiskCache() async throws {
// Given
let writeFileExpectation = XCTestExpectation(description: "FileServiceMock.writeFileProvider is called.")
let producedData = Data([4, 5, 6])
let writtenURL = LockIsolated(nil)
let writtenData = LockIsolated(nil)
let service = CachedJotFilePreviewImageService(
localFileService: FileServiceMock(
temporaryDirectoryProvider: {
FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
},
readFileProvider: { _ in throw NSError(domain: "noDiskCache", code: 0) },
writeFileProvider: { receivedURL, receivedData in
writtenURL.withValue { $0 = receivedURL }
writtenData.withValue { $0 = receivedData }
writeFileExpectation.fulfill()
}
),
jotFilePreviewImageService: JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { _, _, _ in producedData }
)
)
// When
let data = try await service.getPreviewImageData(
jotFileInfo: info,
userInterfaceStyle: .light,
displayScale: 2.0
)
// Then
XCTAssertEqual(data, producedData)
await fulfillment(of: [writeFileExpectation], timeout: 0.5)
XCTAssertEqual(writtenData.value, producedData)
XCTAssertNotNil(writtenURL.value)
}
func test_getPreviewImageData_givenJotFileInfoWithoutModificationDate_skipsDiskCache() async throws {
// Given
let readFileExpectation = XCTestExpectation(description: "FileServiceMock.readFileProvider is not called.")
readFileExpectation.isInverted = true
let writeFileExpectation = XCTestExpectation(description: "FileServiceMock.writeFileProvider is not called.")
writeFileExpectation.isInverted = true
let undatedInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let service = CachedJotFilePreviewImageService(
localFileService: FileServiceMock(
readFileProvider: { _ in
readFileExpectation.fulfill()
return Data()
},
writeFileProvider: { _, _ in writeFileExpectation.fulfill() }
),
jotFilePreviewImageService: JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { _, _, _ in Data([1]) }
)
)
// When
_ = try await service.getPreviewImageData(
jotFileInfo: undatedInfo,
userInterfaceStyle: .light,
displayScale: 2.0
)
// Then
await fulfillment(of: [readFileExpectation, writeFileExpectation], timeout: 0.2)
}
func test_getPreviewImageData_givenDiskCacheHit_skipsUnderlyingService() async throws {
// Given
let cachedData = Data([42])
let underlying = JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { _, _, _ in
XCTFail("Underlying service should not be called when the disk cache is warm.")
return Data()
}
)
let service = CachedJotFilePreviewImageService(
localFileService: FileServiceMock(
readFileProvider: { _ in cachedData }
),
jotFilePreviewImageService: underlying
)
// When
let data = try await service.getPreviewImageData(
jotFileInfo: info,
userInterfaceStyle: .dark,
displayScale: 3.0
)
// Then
XCTAssertEqual(data, cachedData)
}
}
final class LockIsolated: @unchecked Sendable {
private let lock = NSLock()
private var _value: Value
init(_ value: Value) { _value = value }
var value: Value {
lock.withLock { _value }
}
func withValue(_ work: (inout Value) -> Void) {
lock.withLock { work(&_value) }
}
}
================================================
FILE: Tests/JotsPage/CreateJotCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class CreateJotCoordinatorTests: XCTestCase {
func test_start_givenInvoked_presentsAlertWithTitleAndCreateAndCancelActions() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
let alert = viewController as? UIAlertController
XCTAssertNotNil(alert)
XCTAssertEqual(alert?.actions.count, 2)
XCTAssertEqual(alert?.actions.last?.style, .cancel)
presentExpectation.fulfill()
}
}
)
let coordinator = CreateJotCoordinator(
navigation: navigation,
repository: CreateJotRepositoryMock()
)
// When
coordinator.start()
// Then
wait(for: [presentExpectation], timeout: 1)
}
func test_start_givenCancelTapped_invokesOnEnd() {
// Given
let onEndExpectation = XCTestExpectation(description: "CreateJotCoordinator.onEnd is invoked.")
var alertController: UIAlertController?
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
alertController = viewController as? UIAlertController
}
}
)
let coordinator = CreateJotCoordinator(
navigation: navigation,
repository: CreateJotRepositoryMock()
)
coordinator.onEnd = { onEndExpectation.fulfill() }
// When
coordinator.start()
let cancelAction = alertController?.actions.first { $0.style == .cancel }
cancelAction?.invokeHandler()
// Then
wait(for: [onEndExpectation], timeout: 1)
}
}
================================================
FILE: Tests/JotsPage/CreateJotRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class CreateJotRepositoryTests: XCTestCase {
func test_createJot_givenUbiquitousDocumentsDirectoryAvailable_writesToUbiquitousService() async throws {
// Given
let writeProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.writeProvider is called.")
let ubiquitousDocumentsDirectory = URL(staticString: "file:///cloud/Documents/")
let jotFileServiceMock = JotFileServiceMock(
writeProvider: { jotFile in
// Then
XCTAssertEqual(jotFile.info.name, "note")
XCTAssertNotNil(jotFile.info.ubiquitousInfo)
XCTAssertEqual(
jotFile.info.url,
ubiquitousDocumentsDirectory.appendingPathComponent("note", isDirectory: false)
.appendingPathExtension("jot")
)
writeProviderExpectation.fulfill()
}
)
let repository = CreateJotRepository(
localFileService: FileServiceMock(
documentsDirectoryProvider: {
XCTFail("Local file service should not be used when ubiquitous is available.")
return nil
}
),
ubiquitousFileService: FileServiceMock(
documentsDirectoryProvider: { ubiquitousDocumentsDirectory }
),
jotFileService: jotFileServiceMock
)
// When
let info = try await repository.createJot(name: "note")
// Then
XCTAssertNotNil(info.ubiquitousInfo)
await fulfillment(of: [writeProviderExpectation], timeout: 0.2)
}
func test_createJot_givenOnlyLocalDocumentsDirectoryAvailable_writesToLocalService() async throws {
// Given
let writeProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.writeProvider is called.")
let localDocumentsDirectory = URL(staticString: "file:///local/Documents/")
let jotFileServiceMock = JotFileServiceMock(
writeProvider: { jotFile in
// Then
XCTAssertNil(jotFile.info.ubiquitousInfo)
XCTAssertEqual(
jotFile.info.url,
localDocumentsDirectory.appendingPathComponent("note", isDirectory: false).appendingPathExtension(
"jot"
)
)
writeProviderExpectation.fulfill()
}
)
let repository = CreateJotRepository(
localFileService: FileServiceMock(
documentsDirectoryProvider: { localDocumentsDirectory }
),
ubiquitousFileService: FileServiceMock(
documentsDirectoryProvider: { nil }
),
jotFileService: jotFileServiceMock
)
// When
let info = try await repository.createJot(name: "note")
// Then
XCTAssertNil(info.ubiquitousInfo)
await fulfillment(of: [writeProviderExpectation], timeout: 0.2)
}
func test_createJot_givenNoDocumentsDirectoryAvailable_throwsCouldNotCreateFile() async {
// Given
let repository = CreateJotRepository(
localFileService: FileServiceMock(documentsDirectoryProvider: { nil }),
ubiquitousFileService: FileServiceMock(documentsDirectoryProvider: { nil }),
jotFileService: JotFileServiceMock()
)
// When / Then
do {
_ = try await repository.createJot(name: "note")
XCTFail("Expected CreateJotRepository.Failure.couldNotCreateFile.")
} catch CreateJotRepository.Failure.couldNotCreateFile {
// Expected
} catch {
XCTFail("Unexpected error: \(error)")
}
}
func test_createJot_givenFileAlreadyExists_throwsFileExists() async {
// Given
let repository = CreateJotRepository(
localFileService: FileServiceMock(documentsDirectoryProvider: { nil }),
ubiquitousFileService: FileServiceMock(
documentsDirectoryProvider: { URL(staticString: "file:///cloud/Documents/") },
fileExistsProvider: { _ in true }
),
jotFileService: JotFileServiceMock(
writeProvider: { _ in
XCTFail("Should not write when file already exists.")
}
)
)
// When / Then
do {
_ = try await repository.createJot(name: "note")
XCTFail("Expected CreateJotRepository.Failure.fileExists.")
} catch CreateJotRepository.Failure.fileExists {
// Expected
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}
================================================
FILE: Tests/JotsPage/DeleteJotCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class DeleteJotCoordinatorTests: XCTestCase {
func test_start_givenInvoked_presentsAlertWithDestructiveDeleteAction() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
let alert = viewController as? UIAlertController
XCTAssertNotNil(alert)
XCTAssertEqual(alert?.actions.count, 2)
XCTAssertEqual(alert?.actions.last?.style, .destructive)
presentExpectation.fulfill()
}
}
)
let coordinator = DeleteJotCoordinator(
jotFileInfo: info,
navigation: navigation,
repository: DeleteJotRepositoryMock()
)
// When
coordinator.start()
// Then
wait(for: [presentExpectation], timeout: 1)
}
func test_start_givenDeleteActionTapped_invokesRepositoryDeleteAndDismisses() {
// Given
let deleteExpectation = XCTestExpectation(description: "Repository.deleteJot is called.")
let dismissExpectation = XCTestExpectation(description: "Navigation.dismiss is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
var alertController: UIAlertController?
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
alertController = viewController as? UIAlertController
}
},
dismissViewControllerProvider: { _, _ in
dismissExpectation.fulfill()
}
)
let coordinator = DeleteJotCoordinator(
jotFileInfo: info,
navigation: navigation,
repository: DeleteJotRepositoryMock(
deleteJotProvider: { _ in deleteExpectation.fulfill() }
)
)
// When
coordinator.start()
let destructive = alertController?.actions.first { $0.style == .destructive }
destructive?.invokeHandler()
// Then
wait(for: [deleteExpectation, dismissExpectation], timeout: 1)
}
}
================================================
FILE: Tests/JotsPage/DeleteJotRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class DeleteJotRepositoryTests: XCTestCase {
func test_deleteJot_forwardsToJotFileServiceRemove() async throws {
// Given
let removeProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.removeProvider is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let jotFileServiceMock = JotFileServiceMock(
removeProvider: { receivedInfo in
// Then
XCTAssertEqual(receivedInfo, info)
removeProviderExpectation.fulfill()
}
)
let repository = DeleteJotRepository(jotFileService: jotFileServiceMock)
// When
try repository.deleteJot(jotFileInfo: info)
// Then
await fulfillment(of: [removeProviderExpectation], timeout: 0.2)
}
}
================================================
FILE: Tests/JotsPage/EmptyStateCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class EmptyStateCellViewModelTests: XCTestCase {
func test_init_storesTitle() {
// When
let viewModel = EmptyStateCellViewModel(title: "Nothing here yet")
// Then
XCTAssertEqual(viewModel.title, "Nothing here yet")
}
func test_handleContextMenuConfiguration_returnsNilByDefault() {
// Given
let viewModel = EmptyStateCellViewModel(title: "irrelevant")
// When
let configuration = viewModel.handleContextMenuConfiguration(
point: .zero,
sourceView: UIView()
)
// Then
XCTAssertNil(configuration)
}
}
================================================
FILE: Tests/JotsPage/JotBusinessModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class JotBusinessModelTests: XCTestCase {
func test_init_givenLocalJotFileInfo_isDownloadedTrueAndIsDownloadingFalse() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
// When
let model = JotBusinessModel(jotFileInfo: jotFileInfo)
// Then
XCTAssertEqual(model.name, "note")
XCTAssertTrue(model.isDownloaded)
XCTAssertFalse(model.isDownloading)
}
func test_init_givenUbiquitousInfoWithNotDownloaded_isDownloadedFalse() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///cloud/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: true)
)
// When
let model = JotBusinessModel(jotFileInfo: jotFileInfo)
// Then
XCTAssertFalse(model.isDownloaded)
XCTAssertTrue(model.isDownloading)
}
func test_init_givenUbiquitousInfoWithDownloaded_isDownloadedTrue() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///cloud/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .downloaded, isDownloading: false)
)
// When
let model = JotBusinessModel(jotFileInfo: jotFileInfo)
// Then
XCTAssertTrue(model.isDownloaded)
XCTAssertFalse(model.isDownloading)
}
func test_toJotFileInfo_returnsOriginalInfo() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let model = JotBusinessModel(jotFileInfo: jotFileInfo)
// When
let result = model.toJotFileInfo()
// Then
XCTAssertEqual(result, jotFileInfo)
}
}
================================================
FILE: Tests/JotsPage/JotCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class JotCellViewModelTests: XCTestCase {
func test_init_givenIsDownloadingTrue_setsPreviewToLoadingIndicator() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///cloud/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: true)
)
let businessModel = JotBusinessModel(jotFileInfo: jotFileInfo)
// When
let viewModel = JotCellViewModel(
jot: businessModel,
jotMenuConfigurations: makeEmptyJotMenuConfigurations(),
repository: JotsRepositoryMock(),
onAction: {}
)
// Then
XCTAssertEqual(viewModel.preview, .loadingIndicator)
XCTAssertEqual(viewModel.name, "note")
}
func test_init_givenDownloadedAndNotDownloading_setsPreviewToThumbnail() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///cloud/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false)
)
// When
let viewModel = JotCellViewModel(
jot: JotBusinessModel(jotFileInfo: jotFileInfo),
jotMenuConfigurations: makeEmptyJotMenuConfigurations(),
repository: JotsRepositoryMock(),
onAction: {}
)
// Then
XCTAssertEqual(viewModel.preview, .thumbnail)
}
func test_init_givenNotDownloadedAndNotDownloading_setsPreviewToCloudImage() {
// Given
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///cloud/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: false)
)
// When
let viewModel = JotCellViewModel(
jot: JotBusinessModel(jotFileInfo: jotFileInfo),
jotMenuConfigurations: makeEmptyJotMenuConfigurations(),
repository: JotsRepositoryMock(),
onAction: {}
)
// Then
XCTAssertEqual(viewModel.preview, .cloudImage)
}
func test_handleAction_givenTap_invokesOnAction() async {
// Given
let onActionExpectation = XCTestExpectation(description: "onAction is called.")
let jotFileInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let viewModel = JotCellViewModel(
jot: JotBusinessModel(jotFileInfo: jotFileInfo),
jotMenuConfigurations: makeEmptyJotMenuConfigurations(),
repository: JotsRepositoryMock(),
onAction: { onActionExpectation.fulfill() }
)
// When
viewModel.handle(action: .tap)
// Then
await fulfillment(of: [onActionExpectation], timeout: 0.2)
}
func test_getPreviewImage_forwardsToRepositoryWithJotFileInfo() async throws {
// Given
let getPreviewImageProviderExpectation = XCTestExpectation(
description: "JotsRepositoryMock.getPreviewImageProvider is called."
)
let expectedImage = UIImage()
let expectedFileURL = URL(staticString: "file:///tmp/note.jot")
let jotFileInfo = JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let repositoryMock = JotsRepositoryMock(
getPreviewImageProvider: { receivedInfo, receivedStyle, receivedScale in
// Then
XCTAssertEqual(receivedInfo.url, expectedFileURL)
XCTAssertEqual(receivedStyle, .dark)
XCTAssertEqual(receivedScale, 3.0)
getPreviewImageProviderExpectation.fulfill()
return expectedImage
}
)
let viewModel = JotCellViewModel(
jot: JotBusinessModel(jotFileInfo: jotFileInfo),
jotMenuConfigurations: makeEmptyJotMenuConfigurations(),
repository: repositoryMock,
onAction: {}
)
// When
let image = await viewModel.getPreviewImage(userInterfaceStyle: .dark, displayScale: 3.0)
// Then
XCTAssertIdentical(image, expectedImage)
await fulfillment(of: [getPreviewImageProviderExpectation], timeout: 0.2)
}
private func makeEmptyJotMenuConfigurations() -> JotMenuConfigurations {
JotMenuConfigurations { _ in [] }
}
}
================================================
FILE: Tests/JotsPage/JotMenuConfigurationFactoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class JotMenuConfigurationFactoryTests: XCTestCase {
func test_make_givenNoOpenInNewWindow_producesRenameDuplicateDeleteRevealAndShareGroup() {
// Given
let factory = JotMenuConfigurationFactory()
// When
let configurations = factory.make(
onShare: { _, _ in },
onRename: {},
onDuplicate: {},
onDelete: {},
onShowInFiles: {}
)
let resolved = configurations.make(popoverAnchorProvider: { nil })
// Then
XCTAssertEqual(resolved.count, 5)
XCTAssertActionAt(resolved, index: 0, expectedSystemImage: "pencil")
XCTAssertActionAt(resolved, index: 1, expectedSystemImage: "plus.square.on.square")
XCTAssertActionAt(resolved, index: 2, expectedSystemImage: "trash", expectedDestructive: true)
XCTAssertActionAt(resolved, index: 3, expectedSystemImage: "folder")
XCTAssertGroupAt(resolved, index: 4, expectedActionCount: 3)
}
func test_make_givenOpenInNewWindow_prependsOpenInNewWindowAction() {
// Given
let factory = JotMenuConfigurationFactory()
// When
let configurations = factory.make(
onShare: { _, _ in },
onRename: {},
onDuplicate: {},
onDelete: {},
onShowInFiles: {},
onOpenInNewWindow: {}
)
let resolved = configurations.make(popoverAnchorProvider: { nil })
// Then
XCTAssertEqual(resolved.count, 6)
XCTAssertActionAt(resolved, index: 0, expectedSystemImage: "plus.app")
XCTAssertActionAt(resolved, index: 1, expectedSystemImage: "pencil")
}
func test_make_actionHandlers_invokeCorrespondingClosures() async {
// Given
let onRenameExpectation = XCTestExpectation(description: "onRename is called.")
let onDuplicateExpectation = XCTestExpectation(description: "onDuplicate is called.")
let onDeleteExpectation = XCTestExpectation(description: "onDelete is called.")
let onShowInFilesExpectation = XCTestExpectation(description: "onShowInFiles is called.")
let onShareExpectation = XCTestExpectation(description: "onShare is called for each format.")
onShareExpectation.expectedFulfillmentCount = 3
let factory = JotMenuConfigurationFactory()
// When
let configurations = factory.make(
onShare: { _, _ in onShareExpectation.fulfill() },
onRename: { onRenameExpectation.fulfill() },
onDuplicate: { onDuplicateExpectation.fulfill() },
onDelete: { onDeleteExpectation.fulfill() },
onShowInFiles: { onShowInFilesExpectation.fulfill() }
)
let resolved = configurations.make(popoverAnchorProvider: { nil })
invokeAction(resolved[0])
invokeAction(resolved[1])
invokeAction(resolved[2])
invokeAction(resolved[3])
if case let .group(group) = resolved[4] {
for action in group.actions {
action.handler()
}
}
// Then
await fulfillment(
of: [
onRenameExpectation,
onDuplicateExpectation,
onDeleteExpectation,
onShowInFilesExpectation,
onShareExpectation,
],
timeout: 0.2
)
}
private func invokeAction(_ configuration: JotMenuConfiguration) {
if case let .action(action) = configuration {
action.handler()
} else {
XCTFail("Expected .action, got \(configuration).")
}
}
private func XCTAssertActionAt(
_ configurations: [JotMenuConfiguration],
index: Int,
expectedSystemImage: String,
expectedDestructive: Bool = false,
file: StaticString = #filePath,
line: UInt = #line
) {
guard case let .action(action) = configurations[index] else {
XCTFail("Expected .action at index \(index)", file: file, line: line)
return
}
XCTAssertEqual(action.systemImageName, expectedSystemImage, file: file, line: line)
XCTAssertEqual(action.isDestructive, expectedDestructive, file: file, line: line)
}
private func XCTAssertGroupAt(
_ configurations: [JotMenuConfiguration],
index: Int,
expectedActionCount: Int,
file: StaticString = #filePath,
line: UInt = #line
) {
guard case let .group(group) = configurations[index] else {
XCTFail("Expected .group at index \(index)", file: file, line: line)
return
}
XCTAssertEqual(group.actions.count, expectedActionCount, file: file, line: line)
}
}
================================================
FILE: Tests/JotsPage/JotsCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class JotsCoordinatorTests: XCTestCase {
func test_shouldHandle_givenAnyURL_returnsTrue() {
// Given
let coordinator = makeCoordinator()
// Then
XCTAssertTrue(coordinator.shouldHandle(url: URL(staticString: "https://example.com")))
}
func test_handle_givenNonEditJotURL_returnsCachedJotsViewController() {
// Given
let madeViewController = UIViewController()
let coordinator = makeCoordinator(
jotsViewControllerFactory: JotsViewControllerFactoryMock(
makeProvider: { _ in madeViewController }
)
)
// When
let viewControllers = coordinator.handle(url: URL(staticString: "https://example.com"))
// Then
XCTAssertEqual(viewControllers, [madeViewController])
}
func test_handle_givenEditJotURL_returnsJotsAndChildViewControllers() {
// Given
let madeViewController = UIViewController()
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let editJotURL = EditJotURL(jotFileInfo: info).toURL()
let editJotViewController = UIViewController()
let coordinator = makeCoordinator(
jotsViewControllerFactory: JotsViewControllerFactoryMock(
makeProvider: { _ in madeViewController }
),
editJotCoordinatorFactory: EditJotCoordinatorFactoryMock(
makeProvider: { _ in
NavigationCoordinatorMock(
shouldHandleProvider: { _ in true },
handleProvider: { _ in [editJotViewController] }
)
}
)
)
// When
let viewControllers = coordinator.handle(url: editJotURL)
// Then
XCTAssertEqual(viewControllers, [madeViewController, editJotViewController])
}
func test_openSettings_givenInvoked_startsSettingsCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "SettingsCoordinator.start is called.")
let coordinator = makeCoordinator(
settingsCoordinatorFactory: SettingsCoordinatorFactoryMock(
makeProvider: { _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.openSettings()
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_openCreateJot_givenInvoked_startsCreateJotCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "CreateJotCoordinator.start is called.")
let coordinator = makeCoordinator(
createJotCoordinatorFactory: CreateJotCoordinatorFactoryMock(
makeProvider: { _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.openCreateJot()
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_openEnableCloudPage_givenInvoked_startsEnableCloudCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "EnableCloudCoordinator.start is called.")
let coordinator = makeCoordinator(
enableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryMock(
makeProvider: { _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.openEnableCloudPage()
// Then
wait(for: [startExpectation], timeout: 1)
}
#if !targetEnvironment(macCatalyst)
func test_openJot_givenPrefersNewWindow_invokesNavigationOpenScene() {
// Given
let openSceneExpectation = XCTestExpectation(description: "Navigation.openScene is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
openSceneProvider: { receivedURL in
XCTAssertEqual(receivedURL, EditJotURL(jotFileInfo: info).toURL())
openSceneExpectation.fulfill()
}
)
let coordinator = makeCoordinator(navigation: navigation)
// When
coordinator.openJot(jotFileInfo: info, prefersNewWindow: true)
// Then
wait(for: [openSceneExpectation], timeout: 1)
}
func test_openJot_givenPrefersSameWindow_invokesNavigationOpen() {
// Given
let openExpectation = XCTestExpectation(description: "Navigation.open is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
openURLProvider: { receivedURL in
XCTAssertEqual(receivedURL, EditJotURL(jotFileInfo: info).toURL())
openExpectation.fulfill()
}
)
let coordinator = makeCoordinator(navigation: navigation)
// When
coordinator.openJot(jotFileInfo: info, prefersNewWindow: false)
// Then
wait(for: [openExpectation], timeout: 1)
}
#endif
func test_openDeleteJot_givenInvoked_startsDeleteJotCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "DeleteJotCoordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryMock(
makeProvider: { _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.openDeleteJot(jotFileInfo: info)
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_showRenameAlert_givenInvoked_startsRenameJotCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "RenameJotCoordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
renameJotCoordinatorFactory: RenameJotCoordinatorFactoryMock(
makeProvider: { _, _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.showRenameAlert(jotFileInfo: info)
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_showShareJot_givenInvoked_startsShareJotCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "ShareJotCoordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
shareJotCoordinatorFactory: ShareJotCoordinatorFactoryMock(
makeProvider: { _, _, _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.showShareJot(jotFileInfo: info, format: .pdf, configurePopoverAnchor: nil)
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_showInFiles_givenInvoked_startsRevealFileCoordinator() {
// Given
let startExpectation = XCTestExpectation(description: "RevealFileCoordinator.start is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = makeCoordinator(
revealFileCoordinatorFactory: RevealFileCoordinatorFactoryMock(
makeProvider: { _, _ in
CoordinatorMock(startProvider: { startExpectation.fulfill() })
}
)
)
// When
coordinator.showInFiles(jotFileInfo: info)
// Then
wait(for: [startExpectation], timeout: 1)
}
func test_showInfoAlert_givenInvoked_presentsAlertController() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
XCTAssertTrue(viewController is UIAlertController)
presentExpectation.fulfill()
}
}
)
let coordinator = makeCoordinator(navigation: navigation)
// When
coordinator.showInfoAlert(title: "title", message: "message")
// Then
wait(for: [presentExpectation], timeout: 1)
}
private func makeCoordinator(
navigation: Navigation = .test(),
jotsViewControllerFactory: JotsViewControllerFactoryProtocol = JotsViewControllerFactoryMock(),
settingsCoordinatorFactory: SettingsCoordinatorFactoryProtocol = SettingsCoordinatorFactoryMock(),
enableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryProtocol = EnableCloudCoordinatorFactoryMock(),
editJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol = EditJotCoordinatorFactoryMock(),
cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactoryProtocol =
CloudMigrationCoordinatorFactoryMock(),
createJotCoordinatorFactory: CreateJotCoordinatorFactoryProtocol = CreateJotCoordinatorFactoryMock(),
deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol = DeleteJotCoordinatorFactoryMock(),
renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol = RenameJotCoordinatorFactoryMock(),
shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol = ShareJotCoordinatorFactoryMock(),
revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol = RevealFileCoordinatorFactoryMock()
) -> JotsCoordinator {
JotsCoordinator(
navigation: navigation,
jotsViewControllerFactory: jotsViewControllerFactory,
settingsCoordinatorFactory: settingsCoordinatorFactory,
enableCloudCoordinatorFactory: enableCloudCoordinatorFactory,
editJotCoordinatorFactory: editJotCoordinatorFactory,
cloudMigrationCoordinatorFactory: cloudMigrationCoordinatorFactory,
createJotCoordinatorFactory: createJotCoordinatorFactory,
deleteJotCoordinatorFactory: deleteJotCoordinatorFactory,
renameJotCoordinatorFactory: renameJotCoordinatorFactory,
shareJotCoordinatorFactory: shareJotCoordinatorFactory,
revealFileCoordinatorFactory: revealFileCoordinatorFactory
)
}
}
================================================
FILE: Tests/JotsPage/JotsRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class JotsRepositoryTests: XCTestCase {
func test_getJotFiles_givenJotFileInfos_emitsThemSortedByModificationDateDescending() async throws {
// Given
let older = JotFile.Info(
url: URL(staticString: "file:///tmp/older.jot"),
name: "older",
modificationDate: Date(timeIntervalSince1970: 1_000),
ubiquitousInfo: nil
)
let newer = JotFile.Info(
url: URL(staticString: "file:///tmp/newer.jot"),
name: "newer",
modificationDate: Date(timeIntervalSince1970: 2_000),
ubiquitousInfo: nil
)
let undated = JotFile.Info(
url: URL(staticString: "file:///tmp/undated.jot"),
name: "undated",
modificationDate: nil,
ubiquitousInfo: nil
)
let jotFileServiceMock = JotFileServiceMock(
documentsDirectoryContentsProvider: {
AsyncThrowingStream { continuation in
continuation.yield([older, newer, undated])
continuation.finish()
}
}
)
let repository = JotsRepository(
ubiquitousFileService: FileServiceMock(),
applicationService: await ApplicationServiceMock(),
deviceService: await DeviceServiceMock(),
jotFileService: jotFileServiceMock,
jotFilePreviewImageService: JotFilePreviewImageServiceMock()
)
// When
var iterator = repository.getJotFiles().makeAsyncIterator()
let first = try await XCTUnwrapAsync(try await iterator.next())
// Then
XCTAssertEqual(first.map(\.name), ["newer", "older", "undated"])
}
func test_shouldShowEnableICloudButton_givenUbiquitousServiceDisabled_returnsTrue() async {
// Given
let repository = JotsRepository(
ubiquitousFileService: FileServiceMock(isEnabledProvider: { false }),
applicationService: await ApplicationServiceMock(),
deviceService: await DeviceServiceMock(),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock()
)
// Then
XCTAssertTrue(repository.shouldShowEnableICloudButton())
}
func test_shouldShowEnableICloudButton_givenUbiquitousServiceEnabled_returnsFalse() async {
// Given
let repository = JotsRepository(
ubiquitousFileService: FileServiceMock(isEnabledProvider: { true }),
applicationService: await ApplicationServiceMock(),
deviceService: await DeviceServiceMock(),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock()
)
// Then
XCTAssertFalse(repository.shouldShowEnableICloudButton())
}
func test_duplicate_forwardsToJotFileService() async throws {
// Given
let duplicateProviderExpectation = XCTestExpectation(
description: "JotFileServiceMock.duplicateProvider is called."
)
let inputInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let expectedDuplicatedInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/note-1.jot"),
name: "note-1",
modificationDate: nil,
ubiquitousInfo: nil
)
let jotFileServiceMock = JotFileServiceMock(
duplicateProvider: { receivedInfo in
// Then
XCTAssertEqual(receivedInfo, inputInfo)
duplicateProviderExpectation.fulfill()
return expectedDuplicatedInfo
}
)
let repository = JotsRepository(
ubiquitousFileService: FileServiceMock(),
applicationService: await ApplicationServiceMock(),
deviceService: await DeviceServiceMock(),
jotFileService: jotFileServiceMock,
jotFilePreviewImageService: JotFilePreviewImageServiceMock()
)
// When
let result = try repository.duplicate(jotFileInfo: inputInfo)
// Then
XCTAssertEqual(result, expectedDuplicatedInfo)
await fulfillment(of: [duplicateProviderExpectation], timeout: 0.2)
}
func test_download_callsStartDownloadOnUbiquitousFileServiceWithJotFileURL() async throws {
// Given
let startDownloadProviderExpectation = XCTestExpectation(
description: "Ubiquitous startDownloadProvider is called."
)
let expectedFileURL = URL(staticString: "file:///cloud/note.jot")
let ubiquitousFileServiceMock = FileServiceMock(
startDownloadProvider: { receivedFileURL in
// Then
XCTAssertEqual(receivedFileURL, expectedFileURL)
startDownloadProviderExpectation.fulfill()
}
)
let repository = JotsRepository(
ubiquitousFileService: ubiquitousFileServiceMock,
applicationService: await ApplicationServiceMock(),
deviceService: await DeviceServiceMock(),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: JotFilePreviewImageServiceMock()
)
// When
try repository.download(
jotFileInfo: JotFile.Info(
url: expectedFileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: false)
)
)
// Then
await fulfillment(of: [startDownloadProviderExpectation], timeout: 0.2)
}
func test_getPreviewImage_givenServiceThrows_returnsNil() async throws {
// Given
let jotFilePreviewImageServiceMock = JotFilePreviewImageServiceMock(
getPreviewImageDataProvider: { _, _, _ in
throw NSError(domain: "test", code: 0)
}
)
let repository = JotsRepository(
ubiquitousFileService: FileServiceMock(),
applicationService: await ApplicationServiceMock(),
deviceService: await DeviceServiceMock(),
jotFileService: JotFileServiceMock(),
jotFilePreviewImageService: jotFilePreviewImageServiceMock
)
// When
let image = await repository.getPreviewImage(
jotFileInfo: JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
),
userInterfaceStyle: .light,
displayScale: 2.0
)
// Then
XCTAssertNil(image)
}
}
private func XCTUnwrapAsync(
_ expression: @autoclosure () async throws -> T?,
file: StaticString = #filePath,
line: UInt = #line
) async throws -> T {
let value = try await expression()
return try XCTUnwrap(value, file: file, line: line)
}
================================================
FILE: Tests/JotsPage/JotsViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class JotsViewModelTests: XCTestCase {
func test_leftNavigationItems_givenShouldNotShowICloudButton_yieldsOnlySettings() async throws {
// Given
let viewModel = JotsViewModel(
coordinator: JotsCoordinatorMock(),
repository: JotsRepositoryMock(shouldShowEnableICloudButtonProvider: { false }),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
let items = try await firstValue(of: viewModel.leftNavigationItems)
// Then
XCTAssertEqual(items.count, 1)
guard case let .symbol(systemImageName, _) = items[0] else {
XCTFail("Expected .symbol")
return
}
XCTAssertEqual(systemImageName, "gear")
}
func test_leftNavigationItems_givenShouldShowICloudButton_yieldsSettingsAndICloudSlash() async throws {
// Given
let viewModel = JotsViewModel(
coordinator: JotsCoordinatorMock(),
repository: JotsRepositoryMock(shouldShowEnableICloudButtonProvider: { true }),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
let items = try await firstValue(of: viewModel.leftNavigationItems)
// Then
XCTAssertEqual(items.count, 2)
guard case let .symbol(secondImage, _) = items[1] else {
XCTFail("Expected .symbol")
return
}
XCTAssertEqual(secondImage, "icloud.slash")
}
func test_leftNavigationItem_givenSettingsTap_invokesOpenSettings() async throws {
// Given
let openSettingsExpectation = XCTestExpectation(description: "Coordinator.openSettings is called.")
let coordinator = JotsCoordinatorMock(
openSettingsProvider: { openSettingsExpectation.fulfill() }
)
let viewModel = JotsViewModel(
coordinator: coordinator,
repository: JotsRepositoryMock(),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
let items = try await firstValue(of: viewModel.leftNavigationItems)
guard case let .symbol(_, onAction) = items[0] else {
XCTFail("Expected .symbol")
return
}
onAction()
await Task.yield()
// Then
await fulfillment(of: [openSettingsExpectation], timeout: 1)
}
func test_leftNavigationItem_givenICloudTap_invokesOpenEnableCloudPage() async throws {
// Given
let expectation = XCTestExpectation(description: "Coordinator.openEnableCloudPage is called.")
let coordinator = JotsCoordinatorMock(
openEnableCloudPageProvider: { expectation.fulfill() }
)
let viewModel = JotsViewModel(
coordinator: coordinator,
repository: JotsRepositoryMock(shouldShowEnableICloudButtonProvider: { true }),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
let items = try await firstValue(of: viewModel.leftNavigationItems)
guard case let .symbol(_, onAction) = items[1] else {
XCTFail("Expected .symbol")
return
}
onAction()
await Task.yield()
// Then
await fulfillment(of: [expectation], timeout: 1)
}
#if !targetEnvironment(macCatalyst)
func test_rightNavigationItem_givenCreateTap_invokesOpenCreateJot() async throws {
// Given
let expectation = XCTestExpectation(description: "Coordinator.openCreateJot is called.")
let coordinator = JotsCoordinatorMock(
openCreateJotProvider: { expectation.fulfill() }
)
let viewModel = JotsViewModel(
coordinator: coordinator,
repository: JotsRepositoryMock(),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
let items = try await firstValue(of: viewModel.rightNavigationItems)
XCTAssertEqual(items.count, 1)
guard case let .text(_, onAction) = items[0] else {
XCTFail("Expected .text")
return
}
onAction()
await Task.yield()
// Then
await fulfillment(of: [expectation], timeout: 1)
}
#endif
func test_didLoad_givenEmptyJots_yieldsEmptyStateItem() async throws {
// Given
let stream = AsyncThrowingStream<[JotFile.Info], Error> { continuation in
continuation.yield([])
continuation.finish()
}
let viewModel = JotsViewModel(
coordinator: JotsCoordinatorMock(),
repository: JotsRepositoryMock(getJotFilesProvider: { stream }),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
// Then
XCTAssertEqual(items.count, 1)
}
func test_didLoad_givenStreamThrows_logsError() async throws {
// Given
let errorExpectation = XCTestExpectation(description: "LoggerMock.errorProvider is called.")
let stream = AsyncThrowingStream<[JotFile.Info], Error> { continuation in
continuation.finish(throwing: NSError(domain: "test", code: 0))
}
let viewModel = JotsViewModel(
coordinator: JotsCoordinatorMock(),
repository: JotsRepositoryMock(getJotFilesProvider: { stream }),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock(
errorProvider: { message in
if message.contains("Failed to observe jot files") {
errorExpectation.fulfill()
}
}
)
)
// When
viewModel.didLoad()
// Then
await fulfillment(of: [errorExpectation], timeout: 1)
}
func test_didLoad_givenOneJot_yieldsOneJotItem() async throws {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let stream = AsyncThrowingStream<[JotFile.Info], Error> { continuation in
continuation.yield([info])
continuation.finish()
}
let viewModel = JotsViewModel(
coordinator: JotsCoordinatorMock(),
repository: JotsRepositoryMock(getJotFilesProvider: { stream }),
menuConfigurationFactory: JotMenuConfigurationFactory(),
logger: LoggerMock()
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
// Then
XCTAssertEqual(items.count, 1)
}
}
@MainActor
private func firstValue(
of sequence: S
) async throws -> S.Element where S.Element: Sendable {
var iterator = sequence.makeAsyncIterator()
guard let value = try await iterator.next() else {
throw NSError(domain: "JotsViewModelTests", code: 0)
}
return value
}
================================================
FILE: Tests/JotsPage/RenameJotCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class RenameJotCoordinatorTests: XCTestCase {
func test_start_givenInvoked_presentsAlertWithCancelAndRenameActions() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
let alert = viewController as? UIAlertController
XCTAssertNotNil(alert)
XCTAssertEqual(alert?.actions.count, 2)
XCTAssertEqual(alert?.actions.first?.style, .cancel)
presentExpectation.fulfill()
}
}
)
let coordinator = RenameJotCoordinator(
jotFileInfo: info,
navigation: navigation,
repository: RenameJotRepositoryMock(),
onRename: { _ in }
)
// When
coordinator.start()
// Then
wait(for: [presentExpectation], timeout: 1)
}
func test_start_givenCancelTapped_invokesOnEnd() {
// Given
let onEndExpectation = XCTestExpectation(description: "RenameJotCoordinator.onEnd is invoked.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
var alertController: UIAlertController?
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
alertController = viewController as? UIAlertController
}
}
)
let coordinator = RenameJotCoordinator(
jotFileInfo: info,
navigation: navigation,
repository: RenameJotRepositoryMock(),
onRename: { _ in }
)
coordinator.onEnd = { onEndExpectation.fulfill() }
// When
coordinator.start()
let cancelAction = alertController?.actions.first { $0.style == .cancel }
cancelAction?.invokeHandler()
// Then
wait(for: [onEndExpectation], timeout: 1)
}
}
================================================
FILE: Tests/JotsPage/RenameJotRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class RenameJotRepositoryTests: XCTestCase {
func test_rename_forwardsArgumentsToJotFileServiceAndReturnsResult() async throws {
// Given
let renameProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.renameProvider is called.")
let inputInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/old.jot"),
name: "old",
modificationDate: nil,
ubiquitousInfo: nil
)
let expectedRenamedInfo = JotFile.Info(
url: URL(staticString: "file:///tmp/new.jot"),
name: "new",
modificationDate: nil,
ubiquitousInfo: nil
)
let jotFileServiceMock = JotFileServiceMock(
renameProvider: { receivedInfo, receivedName in
// Then
XCTAssertEqual(receivedInfo, inputInfo)
XCTAssertEqual(receivedName, "new")
renameProviderExpectation.fulfill()
return expectedRenamedInfo
}
)
let repository = RenameJotRepository(jotFileService: jotFileServiceMock)
// When
let result = try repository.rename(jotFileInfo: inputInfo, newName: "new")
// Then
XCTAssertEqual(result, expectedRenamedInfo)
await fulfillment(of: [renameProviderExpectation], timeout: 0.2)
}
}
================================================
FILE: Tests/JotsPage/ShareJotCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class ShareJotCoordinatorTests: XCTestCase {
func test_start_givenSuccessfulExport_presentsActivityViewController() async {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present(activity) is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
if viewController is UIActivityViewController {
presentExpectation.fulfill()
}
}
}
)
let coordinator = ShareJotCoordinator(
jotFileInfo: info,
format: .pdf,
navigation: navigation,
repository: ShareJotRepositoryMock(
exportJotProvider: { _, _ in URL(fileURLWithPath: "/tmp/share.pdf") }
),
configurePopoverAnchor: { _ in }
)
// When
coordinator.start()
// Then
await fulfillment(of: [presentExpectation], timeout: 5)
}
func test_start_givenExportThrows_presentsAlertController() async {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present(alert) is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, _ in
MainActor.assumeIsolated {
if viewController is UIAlertController {
presentExpectation.fulfill()
}
}
}
)
let coordinator = ShareJotCoordinator(
jotFileInfo: info,
format: .pdf,
navigation: navigation,
repository: ShareJotRepositoryMock(
exportJotProvider: { _, _ in throw NSError(domain: "test", code: 0) }
),
configurePopoverAnchor: { _ in }
)
// When
coordinator.start()
// Then
await fulfillment(of: [presentExpectation], timeout: 5)
}
}
================================================
FILE: Tests/JotsPage/ShareJotRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@preconcurrency import PencilKit
import UIKit
import XCTest
@testable import Jottre
final class ShareJotRepositoryTests: XCTestCase {
private var temporaryDirectory: URL!
override func setUpWithError() throws {
try super.setUpWithError()
temporaryDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: temporaryDirectory,
withIntermediateDirectories: true
)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: temporaryDirectory)
temporaryDirectory = nil
try super.tearDownWithError()
}
func test_exportJot_givenPDFFormat_writesPDFFileToTemporaryDirectoryAndReturnsItsURL() async throws {
// Given
let repository = try makeRepository()
// When
let resultURL = try await repository.exportJot(jotFileInfo: makeJotFileInfo(), format: .pdf)
// Then
XCTAssertEqual(resultURL.lastPathComponent, "note.pdf")
XCTAssertEqual(resultURL.deletingLastPathComponent(), temporaryDirectory)
XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path))
let header = try Data(contentsOf: resultURL).prefix(4)
XCTAssertEqual(Array(header), Array("%PDF".utf8))
}
func test_exportJot_givenJPGFormat_writesJPGFileToTemporaryDirectoryAndReturnsItsURL() async throws {
// Given
let repository = try makeRepository()
// When
let resultURL = try await repository.exportJot(jotFileInfo: makeJotFileInfo(), format: .jpg)
// Then
XCTAssertEqual(resultURL.lastPathComponent, "note.jpg")
XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path))
let bytes = try Data(contentsOf: resultURL).prefix(3)
XCTAssertEqual(Array(bytes), [0xFF, 0xD8, 0xFF])
}
func test_exportJot_givenPNGFormat_writesPNGFileToTemporaryDirectoryAndReturnsItsURL() async throws {
// Given
let repository = try makeRepository()
// When
let resultURL = try await repository.exportJot(jotFileInfo: makeJotFileInfo(), format: .png)
// Then
XCTAssertEqual(resultURL.lastPathComponent, "note.png")
XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path))
let bytes = try Data(contentsOf: resultURL).prefix(8)
XCTAssertEqual(Array(bytes), [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
}
func test_exportJot_givenJotFileServiceThrows_propagatesError() async {
// Given
struct UnexpectedError: Error {}
let repository = ShareJotRepository(
jotFileService: JotFileServiceMock(
readJotFileProvider: { _ in throw UnexpectedError() }
),
fileService: FileServiceMock(
temporaryDirectoryProvider: { [temporaryDirectory] in temporaryDirectory! }
)
)
// When + Then
do {
_ = try await repository.exportJot(jotFileInfo: makeJotFileInfo(), format: .pdf)
XCTFail("Expected exportJot to throw")
} catch is UnexpectedError {
// Expected
} catch {
XCTFail("Unexpected error: \(error)")
}
}
private func makeRepository() throws -> ShareJotRepository {
let fixtureJot = try loadFixtureJot()
return ShareJotRepository(
jotFileService: JotFileServiceMock(
readJotFileProvider: { jotFileInfo in
JotFile(info: jotFileInfo, jot: fixtureJot)
}
),
fileService: FileServiceMock(
temporaryDirectoryProvider: { [temporaryDirectory] in temporaryDirectory! }
)
)
}
private func loadFixtureJot() throws -> Jot {
let bundle = Bundle(for: ShareJotRepositoryTests.self)
let fixtureURL = try XCTUnwrap(
bundle.url(forResource: "Calculator Pro", withExtension: "jot"),
"Missing 'Calculator Pro.jot' fixture in test bundle resources."
)
let data = try Data(contentsOf: fixtureURL)
return try PropertyListDecoder().decode(Jot.self, from: data)
}
private func makeJotFileInfo() -> JotFile.Info {
JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
}
}
================================================
FILE: Tests/Mocks/ApplicationServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
@MainActor
final class ApplicationServiceMock: ApplicationServiceProtocol {
private let supportsMultipleScenesProvider: @MainActor () -> Bool
private let openProvider: @MainActor (_ url: URL) -> Void
private let canOpenProvider: @MainActor (_ url: URL) -> Bool
init(
supportsMultipleScenesProvider: @MainActor @escaping () -> Bool = { false },
openProvider: @MainActor @escaping (_ url: URL) -> Void = { _ in },
canOpenProvider: @MainActor @escaping (_ url: URL) -> Bool = { _ in true }
) {
self.supportsMultipleScenesProvider = supportsMultipleScenesProvider
self.openProvider = openProvider
self.canOpenProvider = canOpenProvider
}
func supportsMultipleScenes() -> Bool { supportsMultipleScenesProvider() }
func open(url: URL) { openProvider(url) }
func canOpen(url: URL) -> Bool { canOpenProvider(url) }
}
================================================
FILE: Tests/Mocks/BundleServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@testable import Jottre
final class BundleServiceMock: BundleServiceProtocol {
private let shortVersionStringProvider: @Sendable () -> String?
init(shortVersionStringProvider: @Sendable @escaping () -> String? = { nil }) {
self.shortVersionStringProvider = shortVersionStringProvider
}
func shortVersionString() -> String? { shortVersionStringProvider() }
}
================================================
FILE: Tests/Mocks/CloudMigrationCoordinatorMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@testable import Jottre
@MainActor
final class CloudMigrationCoordinatorMock: CloudMigrationCoordinatorProtocol {
var onEnd: (() -> Void)?
private let shouldStartProvider: () -> Bool
private let startProvider: () -> Void
private let showInfoAlertProvider: (_ title: String, _ message: String) -> Void
private let dismissProvider: () -> Void
init(
shouldStartProvider: @escaping () -> Bool = { false },
startProvider: @escaping () -> Void = {},
showInfoAlertProvider: @escaping (_ title: String, _ message: String) -> Void = { _, _ in },
dismissProvider: @escaping () -> Void = {}
) {
self.shouldStartProvider = shouldStartProvider
self.startProvider = startProvider
self.showInfoAlertProvider = showInfoAlertProvider
self.dismissProvider = dismissProvider
}
func shouldStart() -> Bool {
shouldStartProvider()
}
func start() {
startProvider()
}
func showInfoAlert(title: String, message: String) {
showInfoAlertProvider(title, message)
}
func dismiss() {
dismissProvider()
}
}
================================================
FILE: Tests/Mocks/CloudMigrationRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import UIKit
@testable import Jottre
final class CloudMigrationRepositoryMock: CloudMigrationRepositoryProtocol {
private let getJotFilesProvider: @Sendable () -> AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error>
private let moveJotFileProvider:
@Sendable (_ jotFileInfo: JotFile.Info, _ shouldBecomeUbiquitous: Bool) async throws -> Void
private let getShouldShowCloudMigrationProvider: @Sendable () -> Bool
private let markCloudMigrationPageDoneProvider: @Sendable () -> Void
private let getPreviewImageProvider:
@Sendable (
_ jotFileInfo: JotFile.Info,
_ userInterfaceStyle: UIUserInterfaceStyle,
_ displayScale: CGFloat
) async -> UIImage?
init(
getJotFilesProvider:
@Sendable @escaping () -> AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> = {
AsyncThrowingStream { $0.finish() }
},
moveJotFileProvider:
@Sendable @escaping (_ jotFileInfo: JotFile.Info, _ shouldBecomeUbiquitous: Bool) async throws -> Void = {
_,
_ in
},
getShouldShowCloudMigrationProvider: @Sendable @escaping () -> Bool = { false },
markCloudMigrationPageDoneProvider: @Sendable @escaping () -> Void = {},
getPreviewImageProvider:
@Sendable @escaping (
_ jotFileInfo: JotFile.Info,
_ userInterfaceStyle: UIUserInterfaceStyle,
_ displayScale: CGFloat
) async -> UIImage? = { _, _, _ in nil }
) {
self.getJotFilesProvider = getJotFilesProvider
self.moveJotFileProvider = moveJotFileProvider
self.getShouldShowCloudMigrationProvider = getShouldShowCloudMigrationProvider
self.markCloudMigrationPageDoneProvider = markCloudMigrationPageDoneProvider
self.getPreviewImageProvider = getPreviewImageProvider
}
func getJotFiles() -> AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> {
getJotFilesProvider()
}
func moveJotFile(jotFileInfo: JotFile.Info, shouldBecomeUbiquitous: Bool) async throws {
try await moveJotFileProvider(jotFileInfo, shouldBecomeUbiquitous)
}
func getShouldShowCloudMigration() -> Bool {
getShouldShowCloudMigrationProvider()
}
func markCloudMigrationPageDone() {
markCloudMigrationPageDoneProvider()
}
func getPreviewImage(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
await getPreviewImageProvider(jotFileInfo, userInterfaceStyle, displayScale)
}
}
================================================
FILE: Tests/Mocks/CloudMigrationViewControllerFactoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class CloudMigrationViewControllerFactoryMock: CloudMigrationViewControllerFactoryProtocol {
private let makeProvider: @MainActor (_ viewModel: CloudMigrationViewModel) -> UIViewController
init(
makeProvider: @MainActor @escaping (_ viewModel: CloudMigrationViewModel) -> UIViewController = { _ in
UIViewController()
}
) {
self.makeProvider = makeProvider
}
func make(viewModel: CloudMigrationViewModel) -> UIViewController {
makeProvider(viewModel)
}
}
================================================
FILE: Tests/Mocks/CoordinatorFactoryMocks.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
@MainActor
final class CreateJotCoordinatorFactoryMock: CreateJotCoordinatorFactoryProtocol {
private let makeProvider: @MainActor (_ navigation: Navigation) -> Coordinator
init(
makeProvider: @MainActor @escaping (_ navigation: Navigation) -> Coordinator = { _ in CoordinatorMock() }
) {
self.makeProvider = makeProvider
}
func make(navigation: Navigation) -> Coordinator {
makeProvider(navigation)
}
}
@MainActor
final class DeleteJotCoordinatorFactoryMock: DeleteJotCoordinatorFactoryProtocol {
private let makeProvider: @MainActor (_ jotFileInfo: JotFile.Info, _ navigation: Navigation) -> Coordinator
init(
makeProvider:
@MainActor @escaping (_ jotFileInfo: JotFile.Info, _ navigation: Navigation) -> Coordinator = { _, _ in
CoordinatorMock()
}
) {
self.makeProvider = makeProvider
}
func make(jotFileInfo: JotFile.Info, navigation: Navigation) -> Coordinator {
makeProvider(jotFileInfo, navigation)
}
}
@MainActor
final class RenameJotCoordinatorFactoryMock: RenameJotCoordinatorFactoryProtocol {
private let makeProvider:
@MainActor (
_ jotFileInfo: JotFile.Info,
_ navigation: Navigation,
_ onRename: @Sendable (_ renameJotFileInfo: JotFile.Info) -> Void
) -> Coordinator
init(
makeProvider:
@MainActor @escaping (
_ jotFileInfo: JotFile.Info,
_ navigation: Navigation,
_ onRename: @Sendable (_ renameJotFileInfo: JotFile.Info) -> Void
) -> Coordinator = { _, _, _ in CoordinatorMock() }
) {
self.makeProvider = makeProvider
}
func make(
jotFileInfo: JotFile.Info,
navigation: Navigation,
onRename: @Sendable @escaping (_ renameJotFileInfo: JotFile.Info) -> Void
) -> Coordinator {
makeProvider(jotFileInfo, navigation, onRename)
}
}
@MainActor
final class ShareJotCoordinatorFactoryMock: ShareJotCoordinatorFactoryProtocol {
private let makeProvider:
@MainActor (
_ jotFileInfo: JotFile.Info,
_ format: ShareFormat,
_ navigation: Navigation,
_ configurePopoverAnchor: PopoverAnchor?
) -> Coordinator
init(
makeProvider:
@MainActor @escaping (
_ jotFileInfo: JotFile.Info,
_ format: ShareFormat,
_ navigation: Navigation,
_ configurePopoverAnchor: PopoverAnchor?
) -> Coordinator = { _, _, _, _ in CoordinatorMock() }
) {
self.makeProvider = makeProvider
}
func make(
jotFileInfo: JotFile.Info,
format: ShareFormat,
navigation: Navigation,
configurePopoverAnchor: PopoverAnchor?
) -> Coordinator {
makeProvider(jotFileInfo, format, navigation, configurePopoverAnchor)
}
}
@MainActor
final class RevealFileCoordinatorFactoryMock: RevealFileCoordinatorFactoryProtocol {
private let makeProvider: @MainActor (_ jotFileInfo: JotFile.Info, _ navigation: Navigation) -> Coordinator
init(
makeProvider:
@MainActor @escaping (_ jotFileInfo: JotFile.Info, _ navigation: Navigation) -> Coordinator = { _, _ in
CoordinatorMock()
}
) {
self.makeProvider = makeProvider
}
func make(jotFileInfo: JotFile.Info, navigation: Navigation) -> Coordinator {
makeProvider(jotFileInfo, navigation)
}
}
@MainActor
final class JotConflictCoordinatorFactoryMock: JotConflictCoordinatorFactoryProtocol {
private let makeProvider:
@MainActor (
_ jotFileInfo: JotFile.Info,
_ jotFileVersions: [JotFileVersion],
_ navigation: Navigation,
_ onResult: @Sendable (_ result: JotConflictResult) -> Void
) -> Coordinator
init(
makeProvider:
@MainActor @escaping (
_ jotFileInfo: JotFile.Info,
_ jotFileVersions: [JotFileVersion],
_ navigation: Navigation,
_ onResult: @Sendable (_ result: JotConflictResult) -> Void
) -> Coordinator = { _, _, _, _ in CoordinatorMock() }
) {
self.makeProvider = makeProvider
}
func make(
jotFileInfo: JotFile.Info,
jotFileVersions: [JotFileVersion],
navigation: Navigation,
onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void
) -> Coordinator {
makeProvider(jotFileInfo, jotFileVersions, navigation, onResult)
}
}
================================================
FILE: Tests/Mocks/CoordinatorMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@testable import Jottre
@MainActor
final class CoordinatorMock: Coordinator {
var onEnd: (() -> Void)?
private let startProvider: () -> Void
init(
startProvider: @escaping () -> Void = {}
) {
self.startProvider = startProvider
}
func start() {
startProvider()
}
}
================================================
FILE: Tests/Mocks/CreateJotRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class CreateJotRepositoryMock: CreateJotRepositoryProtocol {
private let createJotProvider: @Sendable (_ name: String) async throws -> JotFile.Info
init(
createJotProvider:
@Sendable @escaping (_ name: String) async throws -> JotFile.Info = { name in
JotFile.Info(
url: URL(fileURLWithPath: "/tmp/\(name).jot"),
name: name,
modificationDate: nil,
ubiquitousInfo: nil
)
}
) {
self.createJotProvider = createJotProvider
}
func createJot(name: String) async throws -> JotFile.Info {
try await createJotProvider(name)
}
}
================================================
FILE: Tests/Mocks/DefaultsServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class DefaultsServiceMock: DefaultsServiceProtocol, @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]
private var continuations: [String: [Any]] = [:]
init(initialValues: [String: any LosslessStringConvertible & Sendable] = [:]) {
storage = initialValues
}
func getValue(_ defaultsKey: DefaultsKey) -> T? {
lock.withLock {
storage[defaultsKey.description] as? T
}
}
func set(_ defaultsKey: DefaultsKey, value: T?) {
let listeners: [AsyncStream.Continuation] = lock.withLock {
if let value {
storage[defaultsKey.description] = value
} else {
storage.removeValue(forKey: defaultsKey.description)
}
return (continuations[defaultsKey.description] ?? [])
.compactMap { $0 as? AsyncStream.Continuation }
}
for continuation in listeners {
continuation.yield(value)
}
}
func getValueStream(_ defaultsKey: DefaultsKey) -> AsyncStream {
AsyncStream { [weak self] continuation in
guard let self else {
continuation.finish()
return
}
continuation.yield(self.getValue(defaultsKey))
self.lock.withLock {
self.continuations[defaultsKey.description, default: []].append(continuation)
}
}
}
}
================================================
FILE: Tests/Mocks/DeleteJotRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class DeleteJotRepositoryMock: DeleteJotRepositoryProtocol {
private let deleteJotProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> Void
init(
deleteJotProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> Void = { _ in }
) {
self.deleteJotProvider = deleteJotProvider
}
func deleteJot(jotFileInfo: JotFile.Info) throws {
try deleteJotProvider(jotFileInfo)
}
}
================================================
FILE: Tests/Mocks/DeviceServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@testable import Jottre
@MainActor
final class DeviceServiceMock: DeviceServiceProtocol {
private let isIPadOSProvider: @MainActor () -> Bool
init(isIPadOSProvider: @MainActor @escaping () -> Bool = { false }) {
self.isIPadOSProvider = isIPadOSProvider
}
func isIPadOS() -> Bool { isIPadOSProvider() }
}
================================================
FILE: Tests/Mocks/EditJotCoordinatorMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class EditJotCoordinatorMock: EditJotCoordinatorProtocol {
private let shouldHandleProvider: (_ url: URL) -> Bool
private let handleProvider: (_ url: URL) -> [UIViewController]
private let showShareJotProvider:
(_ jotFileInfo: JotFile.Info, _ format: ShareFormat, _ configurePopoverAnchor: PopoverAnchor?) -> Void
private let showRenameAlertProvider: (_ jotFileInfo: JotFile.Info) -> Void
private let openDeleteJotProvider: (_ jotFileInfo: JotFile.Info) -> Void
private let openJotProvider: (_ jotFileInfo: JotFile.Info) -> Void
private let showInFilesProvider: (_ jotFileInfo: JotFile.Info) -> Void
private let showJotConflictPageProvider:
(
_ jotFileInfo: JotFile.Info,
_ jotFileVersions: [JotFileVersion],
_ onResult: @Sendable (_ result: JotConflictResult) -> Void
) -> Void
private let canGoBackProvider: () -> Bool
private let goBackProvider: () -> Void
private let showInfoAlertProvider: (_ title: String, _ message: String) -> Void
init(
shouldHandleProvider: @escaping (_ url: URL) -> Bool = { _ in false },
handleProvider: @escaping (_ url: URL) -> [UIViewController] = { _ in [] },
showShareJotProvider:
@escaping (_ jotFileInfo: JotFile.Info, _ format: ShareFormat, _ configurePopoverAnchor: PopoverAnchor?) ->
Void = { _, _, _ in },
showRenameAlertProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in },
openDeleteJotProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in },
openJotProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in },
showInFilesProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in },
showJotConflictPageProvider:
@escaping (
_ jotFileInfo: JotFile.Info,
_ jotFileVersions: [JotFileVersion],
_ onResult: @Sendable (_ result: JotConflictResult) -> Void
) -> Void = { _, _, _ in },
canGoBackProvider: @escaping () -> Bool = { false },
goBackProvider: @escaping () -> Void = {},
showInfoAlertProvider: @escaping (_ title: String, _ message: String) -> Void = { _, _ in }
) {
self.shouldHandleProvider = shouldHandleProvider
self.handleProvider = handleProvider
self.showShareJotProvider = showShareJotProvider
self.showRenameAlertProvider = showRenameAlertProvider
self.openDeleteJotProvider = openDeleteJotProvider
self.openJotProvider = openJotProvider
self.showInFilesProvider = showInFilesProvider
self.showJotConflictPageProvider = showJotConflictPageProvider
self.canGoBackProvider = canGoBackProvider
self.goBackProvider = goBackProvider
self.showInfoAlertProvider = showInfoAlertProvider
}
func shouldHandle(url: URL) -> Bool {
shouldHandleProvider(url)
}
func handle(url: URL) -> [UIViewController] {
handleProvider(url)
}
func showShareJot(
jotFileInfo: JotFile.Info,
format: ShareFormat,
configurePopoverAnchor: PopoverAnchor?
) {
showShareJotProvider(jotFileInfo, format, configurePopoverAnchor)
}
func showRenameAlert(jotFileInfo: JotFile.Info) {
showRenameAlertProvider(jotFileInfo)
}
func openDeleteJot(jotFileInfo: JotFile.Info) {
openDeleteJotProvider(jotFileInfo)
}
func openJot(jotFileInfo: JotFile.Info) {
openJotProvider(jotFileInfo)
}
func showInFiles(jotFileInfo: JotFile.Info) {
showInFilesProvider(jotFileInfo)
}
func showJotConflictPage(
jotFileInfo: JotFile.Info,
jotFileVersions: [JotFileVersion],
onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void
) {
showJotConflictPageProvider(jotFileInfo, jotFileVersions, onResult)
}
func canGoBack() -> Bool {
canGoBackProvider()
}
func goBack() {
goBackProvider()
}
func showInfoAlert(title: String, message: String) {
showInfoAlertProvider(title, message)
}
}
================================================
FILE: Tests/Mocks/EditJotRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import CoreGraphics
import Foundation
@preconcurrency import PencilKit
@testable import Jottre
final class EditJotRepositoryMock: EditJotRepositoryProtocol {
private let ubiquitousInfoProvider: @Sendable (_ url: URL) -> UbiquitousInfo?
private let readDrawingProvider:
@Sendable (_ jotFileInfo: JotFile.Info) async throws -> (drawing: PKDrawing, width: CGFloat)
private let writeDrawingProvider: @Sendable (_ jotFileInfo: JotFile.Info, _ drawing: PKDrawing) async throws -> Void
private let getConflictingVersionsProvider: @Sendable (_ jotFileInfo: JotFile.Info) -> [JotFileVersion]?
private let duplicateProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info
init(
ubiquitousInfoProvider: @Sendable @escaping (_ url: URL) -> UbiquitousInfo? = { _ in nil },
readDrawingProvider:
@Sendable @escaping (_ jotFileInfo: JotFile.Info) async throws -> (drawing: PKDrawing, width: CGFloat) = {
_ in (PKDrawing(), 800)
},
writeDrawingProvider:
@Sendable @escaping (_ jotFileInfo: JotFile.Info, _ drawing: PKDrawing) async throws -> Void = { _, _ in },
getConflictingVersionsProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) -> [JotFileVersion]? = { _ in
nil
},
duplicateProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info = { jotFileInfo in
jotFileInfo
}
) {
self.ubiquitousInfoProvider = ubiquitousInfoProvider
self.readDrawingProvider = readDrawingProvider
self.writeDrawingProvider = writeDrawingProvider
self.getConflictingVersionsProvider = getConflictingVersionsProvider
self.duplicateProvider = duplicateProvider
}
func ubiquitousInfo(url: URL) -> UbiquitousInfo? {
ubiquitousInfoProvider(url)
}
func readDrawing(jotFileInfo: JotFile.Info) async throws -> (drawing: PKDrawing, width: CGFloat) {
try await readDrawingProvider(jotFileInfo)
}
func writeDrawing(jotFileInfo: JotFile.Info, drawing: PKDrawing) async throws {
try await writeDrawingProvider(jotFileInfo, drawing)
}
func getConflictingVersions(jotFileInfo: JotFile.Info) -> [JotFileVersion]? {
getConflictingVersionsProvider(jotFileInfo)
}
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info {
try duplicateProvider(jotFileInfo)
}
}
================================================
FILE: Tests/Mocks/EditJotViewControllerFactoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class EditJotViewControllerFactoryMock: EditJotViewControllerFactoryProtocol {
private let makeProvider:
@MainActor (_ jotFileInfo: JotFile.Info, _ coordinator: EditJotCoordinatorProtocol) -> UIViewController
init(
makeProvider:
@MainActor @escaping (_ jotFileInfo: JotFile.Info, _ coordinator: EditJotCoordinatorProtocol) ->
UIViewController = { _, _ in UIViewController() }
) {
self.makeProvider = makeProvider
}
func make(jotFileInfo: JotFile.Info, coordinator: EditJotCoordinatorProtocol) -> UIViewController {
makeProvider(jotFileInfo, coordinator)
}
}
================================================
FILE: Tests/Mocks/EnableCloudCoordinatorMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@testable import Jottre
@MainActor
final class EnableCloudCoordinatorMock: EnableCloudCoordinatorProtocol {
var onEnd: (() -> Void)?
private let startProvider: () -> Void
private let openLearnHowToEnableProvider: () -> Void
private let dismissProvider: () -> Void
init(
startProvider: @escaping () -> Void = {},
openLearnHowToEnableProvider: @escaping () -> Void = {},
dismissProvider: @escaping () -> Void = {}
) {
self.startProvider = startProvider
self.openLearnHowToEnableProvider = openLearnHowToEnableProvider
self.dismissProvider = dismissProvider
}
func start() {
startProvider()
}
func openLearnHowToEnable() {
openLearnHowToEnableProvider()
}
func dismiss() {
dismissProvider()
}
}
================================================
FILE: Tests/Mocks/EnableCloudViewControllerFactoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class EnableCloudViewControllerFactoryMock: EnableCloudViewControllerFactoryProtocol {
private let makeProvider: @MainActor (_ coordinator: EnableCloudCoordinatorProtocol) -> UIViewController
init(
makeProvider: @MainActor @escaping (_ coordinator: EnableCloudCoordinatorProtocol) -> UIViewController = { _ in
UIViewController()
}
) {
self.makeProvider = makeProvider
}
func make(coordinator: EnableCloudCoordinatorProtocol) -> UIViewController {
makeProvider(coordinator)
}
}
================================================
FILE: Tests/Mocks/FileConflictServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class FileConflictServiceMock: FileConflictServiceProtocol {
private let getConflictingVersionsProvider: @Sendable (_ fileURL: URL) -> [NSFileVersion]?
private let resolveVersionConflictsProvider: @Sendable (_ fileURL: URL, _ resolvedVersions: [URL]) throws -> Void
private let copyVersionToTemporaryProvider: @Sendable (_ fileURL: URL, _ versionURL: URL) throws -> URL?
init(
getConflictingVersionsProvider: @Sendable @escaping (_ fileURL: URL) -> [NSFileVersion]? = { _ in nil },
resolveVersionConflictsProvider:
@Sendable @escaping (_ fileURL: URL, _ resolvedVersions: [URL]) throws -> Void = { _, _ in },
copyVersionToTemporaryProvider:
@Sendable @escaping (_ fileURL: URL, _ versionURL: URL) throws -> URL? = { _, _ in nil }
) {
self.getConflictingVersionsProvider = getConflictingVersionsProvider
self.resolveVersionConflictsProvider = resolveVersionConflictsProvider
self.copyVersionToTemporaryProvider = copyVersionToTemporaryProvider
}
func getConflictingVersions(fileURL: URL) -> [NSFileVersion]? {
getConflictingVersionsProvider(fileURL)
}
func resolveVersionConflicts(fileURL: URL, resolvedVersions: [URL]) throws {
try resolveVersionConflictsProvider(fileURL, resolvedVersions)
}
func copyVersionToTemporary(fileURL: URL, versionURL: URL) throws -> URL? {
try copyVersionToTemporaryProvider(fileURL, versionURL)
}
}
================================================
FILE: Tests/Mocks/FileServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class FileServiceMock: FileServiceProtocol {
private let isEnabledProvider: @Sendable () -> Bool
private let initializeDocumentsDirectoryProvider: @Sendable () async throws -> Void
private let documentsDirectoryProvider: @Sendable () async throws -> URL?
private let temporaryDirectoryProvider: @Sendable () -> URL
private let listContentsProvider: @Sendable (_ directory: URL, _ properties: [URLResourceKey]) throws -> [URL]
private let ubiquitousInfoProvider: @Sendable (_ url: URL) -> UbiquitousInfo?
private let startDownloadProvider: @Sendable (_ fileURL: URL) throws -> Void
private let directoryChangesProvider: @Sendable (_ directory: URL) -> AsyncStream
private let readFileProvider: @Sendable (_ fileURL: URL) throws -> Data
private let writeFileProvider: @Sendable (_ fileURL: URL, _ data: Data) throws -> Void
private let fileExistsProvider: @Sendable (_ fileURL: URL) -> Bool
private let removeFileProvider: @Sendable (_ fileURL: URL) throws -> Void
private let moveFileProvider: @Sendable (_ fileURL: URL, _ newFileURL: URL) throws -> Void
private let duplicateFileProvider: @Sendable (_ fileURL: URL) throws -> URL
init(
isEnabledProvider: @Sendable @escaping () -> Bool = { true },
initializeDocumentsDirectoryProvider: @Sendable @escaping () async throws -> Void = {},
documentsDirectoryProvider: @Sendable @escaping () async throws -> URL? = { nil },
temporaryDirectoryProvider: @Sendable @escaping () -> URL = { URL(fileURLWithPath: NSTemporaryDirectory()) },
listContentsProvider: @Sendable @escaping (_ directory: URL, _ properties: [URLResourceKey]) throws -> [URL] = {
_,
_ in []
},
ubiquitousInfoProvider: @Sendable @escaping (_ url: URL) -> UbiquitousInfo? = { _ in nil },
startDownloadProvider: @Sendable @escaping (_ fileURL: URL) throws -> Void = { _ in },
directoryChangesProvider: @Sendable @escaping (_ directory: URL) -> AsyncStream = { _ in
AsyncStream { $0.finish() }
},
readFileProvider: @Sendable @escaping (_ fileURL: URL) throws -> Data = { _ in Data() },
writeFileProvider: @Sendable @escaping (_ fileURL: URL, _ data: Data) throws -> Void = { _, _ in },
fileExistsProvider: @Sendable @escaping (_ fileURL: URL) -> Bool = { _ in false },
removeFileProvider: @Sendable @escaping (_ fileURL: URL) throws -> Void = { _ in },
moveFileProvider: @Sendable @escaping (_ fileURL: URL, _ newFileURL: URL) throws -> Void = { _, _ in },
duplicateFileProvider: @Sendable @escaping (_ fileURL: URL) throws -> URL = { $0 }
) {
self.isEnabledProvider = isEnabledProvider
self.initializeDocumentsDirectoryProvider = initializeDocumentsDirectoryProvider
self.documentsDirectoryProvider = documentsDirectoryProvider
self.temporaryDirectoryProvider = temporaryDirectoryProvider
self.listContentsProvider = listContentsProvider
self.ubiquitousInfoProvider = ubiquitousInfoProvider
self.startDownloadProvider = startDownloadProvider
self.directoryChangesProvider = directoryChangesProvider
self.readFileProvider = readFileProvider
self.writeFileProvider = writeFileProvider
self.fileExistsProvider = fileExistsProvider
self.removeFileProvider = removeFileProvider
self.moveFileProvider = moveFileProvider
self.duplicateFileProvider = duplicateFileProvider
}
func isEnabled() -> Bool { isEnabledProvider() }
func initializeDocumentsDirectory() async throws { try await initializeDocumentsDirectoryProvider() }
func documentsDirectory() async throws -> URL? { try await documentsDirectoryProvider() }
func temporaryDirectory() -> URL { temporaryDirectoryProvider() }
func listContents(directory: URL, properties: [URLResourceKey]) throws -> [URL] {
try listContentsProvider(directory, properties)
}
func ubiquitousInfo(url: URL) -> UbiquitousInfo? { ubiquitousInfoProvider(url) }
func startDownload(fileURL: URL) throws { try startDownloadProvider(fileURL) }
func directoryChanges(directory: URL) -> AsyncStream { directoryChangesProvider(directory) }
func readFile(fileURL: URL) throws -> Data { try readFileProvider(fileURL) }
func writeFile(fileURL: URL, data: Data) throws { try writeFileProvider(fileURL, data) }
func fileExists(fileURL: URL) -> Bool { fileExistsProvider(fileURL) }
func removeFile(fileURL: URL) throws { try removeFileProvider(fileURL) }
func moveFile(fileURL: URL, newFileURL: URL) throws { try moveFileProvider(fileURL, newFileURL) }
func duplicateFile(fileURL: URL) throws -> URL { try duplicateFileProvider(fileURL) }
}
================================================
FILE: Tests/Mocks/JotConflictCoordinatorMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@testable import Jottre
@MainActor
final class JotConflictCoordinatorMock: JotConflictCoordinatorProtocol {
var onEnd: (() -> Void)?
private let startProvider: () -> Void
private let showInfoAlertProvider: (_ title: String, _ message: String) -> Void
private let dismissProvider: (_ completion: @Sendable () -> Void) -> Void
init(
startProvider: @escaping () -> Void = {},
showInfoAlertProvider: @escaping (_ title: String, _ message: String) -> Void = { _, _ in },
dismissProvider: @escaping (_ completion: @Sendable () -> Void) -> Void = { _ in }
) {
self.startProvider = startProvider
self.showInfoAlertProvider = showInfoAlertProvider
self.dismissProvider = dismissProvider
}
func start() {
startProvider()
}
func showInfoAlert(title: String, message: String) {
showInfoAlertProvider(title, message)
}
func dismiss(completion: @Sendable @escaping () -> Void) {
dismissProvider(completion)
}
}
================================================
FILE: Tests/Mocks/JotConflictRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import UIKit
@testable import Jottre
final class JotConflictRepositoryMock: JotConflictRepositoryProtocol {
private let resolveVersionConflictsProvider:
@Sendable (_ jotFileInfo: JotFile.Info, _ resolvedVersions: [JotFileVersion]) throws -> Void
private let getPreviewImageProvider:
@Sendable (
_ jotFileInfo: JotFile.Info,
_ jotFileVersion: JotFileVersion,
_ userInterfaceStyle: UIUserInterfaceStyle,
_ displayScale: CGFloat
) async -> UIImage?
init(
resolveVersionConflictsProvider:
@Sendable @escaping (_ jotFileInfo: JotFile.Info, _ resolvedVersions: [JotFileVersion]) throws -> Void = {
_,
_ in
},
getPreviewImageProvider:
@Sendable @escaping (
_ jotFileInfo: JotFile.Info,
_ jotFileVersion: JotFileVersion,
_ userInterfaceStyle: UIUserInterfaceStyle,
_ displayScale: CGFloat
) async -> UIImage? = { _, _, _, _ in nil }
) {
self.resolveVersionConflictsProvider = resolveVersionConflictsProvider
self.getPreviewImageProvider = getPreviewImageProvider
}
func resolveVersionConflicts(
jotFileInfo: JotFile.Info,
resolvedVersions: [JotFileVersion]
) throws {
try resolveVersionConflictsProvider(jotFileInfo, resolvedVersions)
}
func getPreviewImage(
jotFileInfo: JotFile.Info,
jotFileVersion: JotFileVersion,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
await getPreviewImageProvider(jotFileInfo, jotFileVersion, userInterfaceStyle, displayScale)
}
}
================================================
FILE: Tests/Mocks/JotConflictViewControllerFactoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class JotConflictViewControllerFactoryMock: JotConflictViewControllerFactoryProtocol {
private let makeProvider: @MainActor (_ viewModel: JotConflictViewModel) -> UIViewController
init(
makeProvider: @MainActor @escaping (_ viewModel: JotConflictViewModel) -> UIViewController = { _ in
UIViewController()
}
) {
self.makeProvider = makeProvider
}
func make(viewModel: JotConflictViewModel) -> UIViewController {
makeProvider(viewModel)
}
}
================================================
FILE: Tests/Mocks/JotFileConflictServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
@testable import Jottre
final class JotFileConflictServiceMock: JotFileConflictServiceProtocol {
private let getConfictingVersionsProvider: @Sendable (_ jotFileInfo: JotFile.Info) -> [JotFileVersion]?
private let resolveVersionConflictsProvider:
@Sendable (_ jotFileInfo: JotFile.Info, _ resolvedVersions: [JotFileVersion]) throws -> Void
private let copyVersionToTemporaryProvider:
@Sendable (_ jotFileInfo: JotFile.Info, _ jotFileVersion: JotFileVersion) throws -> JotFile.Info?
init(
getConfictingVersionsProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) -> [JotFileVersion]? = { _ in
nil
},
resolveVersionConflictsProvider:
@Sendable @escaping (_ jotFileInfo: JotFile.Info, _ resolvedVersions: [JotFileVersion]) throws -> Void = {
_,
_ in
},
copyVersionToTemporaryProvider:
@Sendable @escaping (_ jotFileInfo: JotFile.Info, _ jotFileVersion: JotFileVersion) throws -> JotFile.Info? =
{ _, _ in nil }
) {
self.getConfictingVersionsProvider = getConfictingVersionsProvider
self.resolveVersionConflictsProvider = resolveVersionConflictsProvider
self.copyVersionToTemporaryProvider = copyVersionToTemporaryProvider
}
func getConfictingVersions(jotFileInfo: JotFile.Info) -> [JotFileVersion]? {
getConfictingVersionsProvider(jotFileInfo)
}
func resolveVersionConflicts(jotFileInfo: JotFile.Info, resolvedVersions: [JotFileVersion]) throws {
try resolveVersionConflictsProvider(jotFileInfo, resolvedVersions)
}
func copyVersionToTemporary(jotFileInfo: JotFile.Info, jotFileVersion: JotFileVersion) throws -> JotFile.Info? {
try copyVersionToTemporaryProvider(jotFileInfo, jotFileVersion)
}
}
================================================
FILE: Tests/Mocks/JotFilePreviewImageServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import UIKit
@testable import Jottre
final class JotFilePreviewImageServiceMock: JotFilePreviewImageServiceProtocol {
private let getPreviewImageDataProvider:
@Sendable (
_ jotFileInfo: JotFile.Info,
_ userInterfaceStyle: UIUserInterfaceStyle,
_ displayScale: CGFloat
) async throws -> Data
init(
getPreviewImageDataProvider:
@Sendable @escaping (
_ jotFileInfo: JotFile.Info,
_ userInterfaceStyle: UIUserInterfaceStyle,
_ displayScale: CGFloat
) async throws -> Data = { _, _, _ in Data() }
) {
self.getPreviewImageDataProvider = getPreviewImageDataProvider
}
func getPreviewImageData(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async throws -> Data {
try await getPreviewImageDataProvider(jotFileInfo, userInterfaceStyle, displayScale)
}
}
================================================
FILE: Tests/Mocks/JotFileServiceMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class JotFileServiceMock: JotFileServiceProtocol {
private let documentsDirectoryContentsProvider: @Sendable () -> AsyncThrowingStream<[JotFile.Info], Error>
private let readJotFileProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> JotFile
private let writeProvider: @Sendable (_ jotFile: JotFile) throws -> Void
private let duplicateProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info
private let renameProvider: @Sendable (_ jotFileInfo: JotFile.Info, _ newName: String) throws -> JotFile.Info
private let removeProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> Void
private let moveProvider:
@Sendable (_ jotFileInfo: JotFile.Info, _ shouldBecomeUbiquitous: Bool) async throws -> Void
init(
documentsDirectoryContentsProvider: @Sendable @escaping () -> AsyncThrowingStream<[JotFile.Info], Error> = {
AsyncThrowingStream { $0.finish() }
},
readJotFileProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> JotFile = { info in
JotFile(info: info, jot: Jot.makeEmpty())
},
writeProvider: @Sendable @escaping (_ jotFile: JotFile) throws -> Void = { _ in },
duplicateProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info = { $0 },
renameProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ newName: String) throws -> JotFile.Info = {
info,
_ in info
},
removeProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> Void = { _ in },
moveProvider:
@Sendable @escaping (_ jotFileInfo: JotFile.Info, _ shouldBecomeUbiquitous: Bool) async throws -> Void = {
_,
_ in
}
) {
self.documentsDirectoryContentsProvider = documentsDirectoryContentsProvider
self.readJotFileProvider = readJotFileProvider
self.writeProvider = writeProvider
self.duplicateProvider = duplicateProvider
self.renameProvider = renameProvider
self.removeProvider = removeProvider
self.moveProvider = moveProvider
}
func documentsDirectoryContents() -> AsyncThrowingStream<[JotFile.Info], Error> {
documentsDirectoryContentsProvider()
}
func readJotFile(jotFileInfo: JotFile.Info) throws -> JotFile {
try readJotFileProvider(jotFileInfo)
}
func write(jotFile: JotFile) throws {
try writeProvider(jotFile)
}
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info {
try duplicateProvider(jotFileInfo)
}
func rename(jotFileInfo: JotFile.Info, newName: String) throws -> JotFile.Info {
try renameProvider(jotFileInfo, newName)
}
func remove(jotFileInfo: JotFile.Info) throws {
try removeProvider(jotFileInfo)
}
func move(jotFileInfo: JotFile.Info, shouldBecomeUbiquitous: Bool) async throws {
try await moveProvider(jotFileInfo, shouldBecomeUbiquitous)
}
}
================================================
FILE: Tests/Mocks/JotsCoordinatorMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class JotsCoordinatorMock: JotsCoordinatorProtocol {
private let shouldHandleProvider: (_ url: URL) -> Bool
private let handleProvider: (_ url: URL) -> [UIViewController]
private let openSettingsProvider: () -> Void
private let openCreateJotProvider: () -> Void
private let openJotProvider: (_ jotFileInfo: JotFile.Info, _ prefersNewWindow: Bool) -> Void
private let openEnableCloudPageProvider: () -> Void
private let showShareJotProvider:
(_ jotFileInfo: JotFile.Info, _ format: ShareFormat, _ configurePopoverAnchor: PopoverAnchor?) -> Void
private let showRenameAlertProvider: (_ jotFileInfo: JotFile.Info) -> Void
private let openDeleteJotProvider: (_ jotFileInfo: JotFile.Info) -> Void
private let showInfoAlertProvider: (_ title: String, _ message: String) -> Void
private let showInFilesProvider: (_ jotFileInfo: JotFile.Info) -> Void
init(
shouldHandleProvider: @escaping (_ url: URL) -> Bool = { _ in false },
handleProvider: @escaping (_ url: URL) -> [UIViewController] = { _ in [] },
openSettingsProvider: @escaping () -> Void = {},
openCreateJotProvider: @escaping () -> Void = {},
openJotProvider: @escaping (_ jotFileInfo: JotFile.Info, _ prefersNewWindow: Bool) -> Void = { _, _ in },
openEnableCloudPageProvider: @escaping () -> Void = {},
showShareJotProvider:
@escaping (_ jotFileInfo: JotFile.Info, _ format: ShareFormat, _ configurePopoverAnchor: PopoverAnchor?) ->
Void = { _, _, _ in },
showRenameAlertProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in },
openDeleteJotProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in },
showInfoAlertProvider: @escaping (_ title: String, _ message: String) -> Void = { _, _ in },
showInFilesProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in }
) {
self.shouldHandleProvider = shouldHandleProvider
self.handleProvider = handleProvider
self.openSettingsProvider = openSettingsProvider
self.openCreateJotProvider = openCreateJotProvider
self.openJotProvider = openJotProvider
self.openEnableCloudPageProvider = openEnableCloudPageProvider
self.showShareJotProvider = showShareJotProvider
self.showRenameAlertProvider = showRenameAlertProvider
self.openDeleteJotProvider = openDeleteJotProvider
self.showInfoAlertProvider = showInfoAlertProvider
self.showInFilesProvider = showInFilesProvider
}
func shouldHandle(url: URL) -> Bool {
shouldHandleProvider(url)
}
func handle(url: URL) -> [UIViewController] {
handleProvider(url)
}
func openSettings() { openSettingsProvider() }
func openCreateJot() { openCreateJotProvider() }
func openJot(jotFileInfo: JotFile.Info, prefersNewWindow: Bool) {
openJotProvider(jotFileInfo, prefersNewWindow)
}
func openEnableCloudPage() { openEnableCloudPageProvider() }
func showShareJot(
jotFileInfo: JotFile.Info,
format: ShareFormat,
configurePopoverAnchor: PopoverAnchor?
) {
showShareJotProvider(jotFileInfo, format, configurePopoverAnchor)
}
func showRenameAlert(jotFileInfo: JotFile.Info) {
showRenameAlertProvider(jotFileInfo)
}
func openDeleteJot(jotFileInfo: JotFile.Info) {
openDeleteJotProvider(jotFileInfo)
}
func showInfoAlert(title: String, message: String) {
showInfoAlertProvider(title, message)
}
func showInFiles(jotFileInfo: JotFile.Info) {
showInFilesProvider(jotFileInfo)
}
}
================================================
FILE: Tests/Mocks/JotsRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
import UIKit
@testable import Jottre
final class JotsRepositoryMock: JotsRepositoryProtocol {
private let getJotFilesProvider: @Sendable () -> AsyncThrowingStream<[JotFile.Info], Error>
private let shouldShowEnableICloudButtonProvider: @Sendable () -> Bool
private let duplicateProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info
private let downloadProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> Void
private let getPreviewImageProvider:
@Sendable (
_ jotFileInfo: JotFile.Info,
_ userInterfaceStyle: UIUserInterfaceStyle,
_ displayScale: CGFloat
) async -> UIImage?
private let supportsMultipleScenesProvider: @MainActor @Sendable () -> Bool
private let isIPadOSProvider: @MainActor @Sendable () -> Bool
init(
getJotFilesProvider: @Sendable @escaping () -> AsyncThrowingStream<[JotFile.Info], Error> = {
AsyncThrowingStream { $0.finish() }
},
shouldShowEnableICloudButtonProvider: @Sendable @escaping () -> Bool = { false },
duplicateProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info = { $0 },
downloadProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> Void = { _ in },
getPreviewImageProvider:
@Sendable @escaping (
_ jotFileInfo: JotFile.Info,
_ userInterfaceStyle: UIUserInterfaceStyle,
_ displayScale: CGFloat
) async -> UIImage? = { _, _, _ in nil },
supportsMultipleScenesProvider: @MainActor @Sendable @escaping () -> Bool = { false },
isIPadOSProvider: @MainActor @Sendable @escaping () -> Bool = { false }
) {
self.getJotFilesProvider = getJotFilesProvider
self.shouldShowEnableICloudButtonProvider = shouldShowEnableICloudButtonProvider
self.duplicateProvider = duplicateProvider
self.downloadProvider = downloadProvider
self.getPreviewImageProvider = getPreviewImageProvider
self.supportsMultipleScenesProvider = supportsMultipleScenesProvider
self.isIPadOSProvider = isIPadOSProvider
}
func getJotFiles() -> AsyncThrowingStream<[JotFile.Info], Error> {
getJotFilesProvider()
}
func shouldShowEnableICloudButton() -> Bool {
shouldShowEnableICloudButtonProvider()
}
func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info {
try duplicateProvider(jotFileInfo)
}
func download(jotFileInfo: JotFile.Info) throws {
try downloadProvider(jotFileInfo)
}
func getPreviewImage(
jotFileInfo: JotFile.Info,
userInterfaceStyle: UIUserInterfaceStyle,
displayScale: CGFloat
) async -> UIImage? {
await getPreviewImageProvider(jotFileInfo, userInterfaceStyle, displayScale)
}
@MainActor
func supportsMultipleScenes() -> Bool {
supportsMultipleScenesProvider()
}
@MainActor
func isIPadOS() -> Bool {
isIPadOSProvider()
}
}
================================================
FILE: Tests/Mocks/JotsViewControllerFactoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class JotsViewControllerFactoryMock: JotsViewControllerFactoryProtocol {
private let makeProvider: @MainActor (_ coordinator: JotsCoordinatorProtocol) -> UIViewController
init(
makeProvider: @MainActor @escaping (_ coordinator: JotsCoordinatorProtocol) -> UIViewController = { _ in
UIViewController()
}
) {
self.makeProvider = makeProvider
}
func make(coordinator: JotsCoordinatorProtocol) -> UIViewController {
makeProvider(coordinator)
}
}
================================================
FILE: Tests/Mocks/LoggerMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class LoggerMock: LoggerProtocol, @unchecked Sendable {
private let debugProvider: @Sendable (_ message: String) -> Void
private let infoProvider: @Sendable (_ message: String) -> Void
private let errorProvider: @Sendable (_ message: String) -> Void
init(
debugProvider: @Sendable @escaping (_ message: String) -> Void = { _ in },
infoProvider: @Sendable @escaping (_ message: String) -> Void = { _ in },
errorProvider: @Sendable @escaping (_ message: String) -> Void = { _ in }
) {
self.debugProvider = debugProvider
self.infoProvider = infoProvider
self.errorProvider = errorProvider
}
func debug(_ message: @autoclosure () -> String) { debugProvider(message()) }
func info(_ message: @autoclosure () -> String) { infoProvider(message()) }
func error(_ message: @autoclosure () -> String) { errorProvider(message()) }
}
================================================
FILE: Tests/Mocks/PageCoordinatorFactoryMocks.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class SettingsCoordinatorFactoryMock: SettingsCoordinatorFactoryProtocol {
private let makeProvider: @MainActor (_ navigation: Navigation) -> Coordinator
init(
makeProvider: @MainActor @escaping (_ navigation: Navigation) -> Coordinator = { _ in CoordinatorMock() }
) {
self.makeProvider = makeProvider
}
func make(navigation: Navigation) -> Coordinator {
makeProvider(navigation)
}
}
@MainActor
final class EnableCloudCoordinatorFactoryMock: EnableCloudCoordinatorFactoryProtocol {
private let makeProvider: @MainActor (_ navigation: Navigation) -> Coordinator
init(
makeProvider: @MainActor @escaping (_ navigation: Navigation) -> Coordinator = { _ in CoordinatorMock() }
) {
self.makeProvider = makeProvider
}
func make(navigation: Navigation) -> Coordinator {
makeProvider(navigation)
}
}
@MainActor
final class EditJotCoordinatorFactoryMock: EditJotCoordinatorFactoryProtocol {
private let makeProvider: @MainActor (_ navigation: Navigation) -> NavigationCoordinator
init(
makeProvider:
@MainActor @escaping (_ navigation: Navigation) -> NavigationCoordinator = { _ in
NavigationCoordinatorMock()
}
) {
self.makeProvider = makeProvider
}
func make(navigation: Navigation) -> NavigationCoordinator {
makeProvider(navigation)
}
}
@MainActor
final class CloudMigrationCoordinatorFactoryMock: CloudMigrationCoordinatorFactoryProtocol {
private let makeProvider: @MainActor (_ navigation: Navigation) -> CloudMigrationCoordinatorProtocol
init(
makeProvider: @MainActor @escaping (_ navigation: Navigation) -> CloudMigrationCoordinatorProtocol = { _ in
CloudMigrationCoordinatorMock()
}
) {
self.makeProvider = makeProvider
}
func make(navigation: Navigation) -> CloudMigrationCoordinatorProtocol {
makeProvider(navigation)
}
}
@MainActor
final class NavigationCoordinatorMock: NavigationCoordinator {
private let shouldHandleProvider: (_ url: URL) -> Bool
private let handleProvider: (_ url: URL) -> [UIViewController]
init(
shouldHandleProvider: @escaping (_ url: URL) -> Bool = { _ in false },
handleProvider: @escaping (_ url: URL) -> [UIViewController] = { _ in [] }
) {
self.shouldHandleProvider = shouldHandleProvider
self.handleProvider = handleProvider
}
func shouldHandle(url: URL) -> Bool {
shouldHandleProvider(url)
}
func handle(url: URL) -> [UIViewController] {
handleProvider(url)
}
}
================================================
FILE: Tests/Mocks/RenameJotRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class RenameJotRepositoryMock: RenameJotRepositoryProtocol {
private let renameProvider: (_ jotFileInfo: JotFile.Info, _ newName: String) throws -> JotFile.Info
init(
renameProvider:
@escaping (_ jotFileInfo: JotFile.Info, _ newName: String) throws -> JotFile.Info = { jotFileInfo, name in
JotFile.Info(
url: jotFileInfo.url.deletingLastPathComponent().appendingPathComponent("\(name).jot"),
name: name,
modificationDate: nil,
ubiquitousInfo: nil
)
}
) {
self.renameProvider = renameProvider
}
func rename(jotFileInfo: JotFile.Info, newName: String) throws -> JotFile.Info {
try renameProvider(jotFileInfo, newName)
}
}
================================================
FILE: Tests/Mocks/SettingsCoordinatorMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
@MainActor
final class SettingsCoordinatorMock: SettingsCoordinatorProtocol {
var onEnd: (() -> Void)?
private let startProvider: () -> Void
private let openExternalLinkProvider: (_ url: URL) -> Void
private let dismissProvider: () -> Void
init(
startProvider: @escaping () -> Void = {},
openExternalLinkProvider: @escaping (_ url: URL) -> Void = { _ in },
dismissProvider: @escaping () -> Void = {}
) {
self.startProvider = startProvider
self.openExternalLinkProvider = openExternalLinkProvider
self.dismissProvider = dismissProvider
}
func start() {
startProvider()
}
func openExternalLink(url: URL) {
openExternalLinkProvider(url)
}
func dismiss() {
dismissProvider()
}
}
================================================
FILE: Tests/Mocks/SettingsRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
final class SettingsRepositoryMock: SettingsRepositoryProtocol {
private let shouldShowEnableICloudButtonProvider: @Sendable () -> Bool
private let appVersionProvider: @Sendable () -> String
private let userInterfaceStyleProvider: @Sendable () -> AsyncStream
private let updateUserInterfaceStyleProvider: @Sendable (_ style: UIUserInterfaceStyle) -> Void
init(
shouldShowEnableICloudButtonProvider: @Sendable @escaping () -> Bool = { false },
appVersionProvider: @Sendable @escaping () -> String = { "" },
userInterfaceStyleProvider: @Sendable @escaping () -> AsyncStream = {
AsyncStream { $0.finish() }
},
updateUserInterfaceStyleProvider: @Sendable @escaping (_ style: UIUserInterfaceStyle) -> Void = { _ in }
) {
self.shouldShowEnableICloudButtonProvider = shouldShowEnableICloudButtonProvider
self.appVersionProvider = appVersionProvider
self.userInterfaceStyleProvider = userInterfaceStyleProvider
self.updateUserInterfaceStyleProvider = updateUserInterfaceStyleProvider
}
func shouldShowEnableICloudButton() -> Bool {
shouldShowEnableICloudButtonProvider()
}
func appVersion() -> String {
appVersionProvider()
}
func userInterfaceStyle() -> AsyncStream {
userInterfaceStyleProvider()
}
func updateUserInterfaceStyle(_ style: UIUserInterfaceStyle) {
updateUserInterfaceStyleProvider(style)
}
}
================================================
FILE: Tests/Mocks/SettingsViewControllerFactoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
@testable import Jottre
@MainActor
final class SettingsViewControllerFactoryMock: SettingsViewControllerFactoryProtocol {
private let makeProvider: @MainActor (_ coordinator: SettingsCoordinatorProtocol) -> UIViewController
init(
makeProvider: @MainActor @escaping (_ coordinator: SettingsCoordinatorProtocol) -> UIViewController = { _ in
UIViewController()
}
) {
self.makeProvider = makeProvider
}
func make(coordinator: SettingsCoordinatorProtocol) -> UIViewController {
makeProvider(coordinator)
}
}
================================================
FILE: Tests/Mocks/ShareJotRepositoryMock.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import Foundation
@testable import Jottre
final class ShareJotRepositoryMock: ShareJotRepositoryProtocol {
private let exportJotProvider: @Sendable (_ jotFileInfo: JotFile.Info, _ format: ShareFormat) async throws -> URL
init(
exportJotProvider:
@Sendable @escaping (_ jotFileInfo: JotFile.Info, _ format: ShareFormat) async throws -> URL = { _, _ in
URL(fileURLWithPath: "/tmp/share")
}
) {
self.exportJotProvider = exportJotProvider
}
func exportJot(jotFileInfo: JotFile.Info, format: ShareFormat) async throws -> URL {
try await exportJotProvider(jotFileInfo, format)
}
}
================================================
FILE: Tests/Navigation/EditJotURLTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class EditJotURLTests: XCTestCase {
func test_init_givenJotFileInfo_setsFileURLFromInfo() {
// Given
let fileURL = URL(staticString: "file:///tmp/note.jot")
let info = JotFile.Info(
url: fileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
// When
let editJotURL = EditJotURL(jotFileInfo: info)
// Then
XCTAssertEqual(editJotURL.fileURL, fileURL)
XCTAssertEqual(editJotURL.path, "/jots/edit")
}
func test_initFromURL_givenMatchingPathAndQueryItem_succeeds() throws {
// Given
let url = URL(staticString: "scheme:///jots/edit?fileURL=file:///tmp/note.jot")
// When
let editJotURL = try XCTUnwrap(EditJotURL(url: url))
// Then
XCTAssertEqual(editJotURL.fileURL, URL(staticString: "file:///tmp/note.jot"))
}
func test_initFromURL_givenWrongPath_returnsNil() {
// Given
let url = URL(staticString: "scheme:///not/the/right/path?fileURL=file:///tmp/note.jot")
// When
let editJotURL = EditJotURL(url: url)
// Then
XCTAssertNil(editJotURL)
}
func test_initFromURL_givenMissingFileURLQueryItem_returnsNil() {
// Given
let url = URL(staticString: "scheme:///jots/edit")
// When
let editJotURL = EditJotURL(url: url)
// Then
XCTAssertNil(editJotURL)
}
func test_toURL_roundTripsFileURL() throws {
// Given
let fileURL = URL(staticString: "file:///tmp/note.jot")
let original = EditJotURL(
jotFileInfo: JotFile.Info(
url: fileURL,
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
)
// When
let roundTripped = try XCTUnwrap(EditJotURL(url: original.toURL()))
// Then
XCTAssertEqual(roundTripped.fileURL, fileURL)
}
}
================================================
FILE: Tests/Navigation/EnableICloudSupportURLTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class EnableICloudSupportURLTests: XCTestCase {
func test_init_givenLocaleWithLanguageAndRegion_buildsLocalizedSupportPath() {
// Given
let locale = Locale(identifier: "en_US")
// When
let url = EnableICloudSupportURL(locale: locale)
// Then
XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "support.apple.com")
XCTAssertEqual(url.path, "/en-us/guide/icloud/mmfc0f1e2a/icloud")
}
func test_init_givenLocaleWithoutRegion_buildsGenericSupportPath() {
// Given
let locale = Locale(identifier: "en")
// When
let url = EnableICloudSupportURL(locale: locale)
// Then
XCTAssertEqual(url.path, "/guide/icloud/mmfc0f1e2a/icloud")
}
func test_toURL_producesAppleSupportURL() {
// Given
let url = EnableICloudSupportURL(locale: Locale(identifier: "de_DE"))
// When
let resolved = url.toURL()
// Then
XCTAssertEqual(resolved.scheme, "https")
XCTAssertEqual(resolved.host, "support.apple.com")
}
}
================================================
FILE: Tests/Navigation/JotsPageURLTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class JotsPageURLTests: XCTestCase {
func test_path_isRoot() {
// Given / When
let url = JotsPageURL()
// Then
XCTAssertEqual(url.path, "/")
}
func test_toURL_producesURLWithRootPath() {
// Given
let url = JotsPageURL()
// When
let result = url.toURL()
// Then
XCTAssertEqual(result.path, "/")
}
}
================================================
FILE: Tests/Navigation/JottreGithubURLTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class JottreGithubURLTests: XCTestCase {
func test_components_pointToProjectGithubRepository() {
// Given / When
let url = JottreGithubURL()
// Then
XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "github.com")
XCTAssertEqual(url.path, "/antonlorani/jottre")
}
func test_toURL_producesAbsoluteURL() {
// When
let resolved = JottreGithubURL().toURL()
// Then
XCTAssertEqual(resolved.absoluteString, "https://github.com/antonlorani/jottre")
}
}
================================================
FILE: Tests/Navigation/RevealFileURLTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class RevealFileURLTests: XCTestCase {
func test_init_givenJotFileInfo_extractsPathFromInfoURL() {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/dir/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
// When
let revealFileURL = RevealFileURL(jotFileInfo: info)
// Then
XCTAssertEqual(revealFileURL.scheme, "shareddocuments")
XCTAssertEqual(revealFileURL.host, "")
XCTAssertEqual(revealFileURL.path, "/tmp/dir/note.jot")
}
func test_toURL_producesSharedDocumentsURL() throws {
// Given
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/dir/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
// When
let result = RevealFileURL(jotFileInfo: info).toURL()
// Then
XCTAssertEqual(result.scheme, "shareddocuments")
XCTAssertEqual(result.path, "/tmp/dir/note.jot")
}
}
================================================
FILE: Tests/PageViewController/IOS18SymbolBarButtonItemFactoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class IOS18SymbolBarButtonItemFactoryTests: XCTestCase {
func test_make_givenActionPrimary_returnsBarButtonItemWrappingButtonWithAction() {
// Given
let factory = IOS18SymbolBarButtonItemFactory()
let action = UIAction { _ in }
// When
let barButtonItem = factory.make(symbolName: "plus", primaryAction: .action(action))
// Then
let button = try? XCTUnwrap(barButtonItem.customView as? UIButton)
XCTAssertNotNil(button)
XCTAssertFalse(button?.showsMenuAsPrimaryAction ?? true)
}
func test_make_givenMenuPrimary_returnsBarButtonItemWrappingButtonWithMenu() throws {
// Given
let factory = IOS18SymbolBarButtonItemFactory()
let menu = UIMenu(title: "Menu", children: [])
// When
let barButtonItem = factory.make(symbolName: "ellipsis", primaryAction: .menu(menu))
// Then
let button = try XCTUnwrap(barButtonItem.customView as? UIButton)
XCTAssertTrue(button.showsMenuAsPrimaryAction)
XCTAssertNotNil(button.menu)
}
}
================================================
FILE: Tests/PageViewController/IOS18TextBarButtonItemFactoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class IOS18TextBarButtonItemFactoryTests: XCTestCase {
func test_make_returnsBarButtonItemWrappingButtonConfiguredWithTitle() throws {
// Given
let factory = IOS18TextBarButtonItemFactory()
let action = UIAction { _ in }
// When
let barButtonItem = factory.make(title: "Save", primaryAction: action)
// Then
let button = try XCTUnwrap(barButtonItem.customView as? UIButton)
XCTAssertEqual(button.configuration?.title, "Save")
}
}
================================================
FILE: Tests/PageViewController/PageCellSizingStrategyTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class PageCellSizingStrategyTests: XCTestCase {
func test_columnSpacing_givenFullWidth_isZero() {
// Given
let strategy = PageCellSizingStrategy.fullWidth()
// Then
XCTAssertEqual(strategy.columnSpacing, .zero)
}
func test_columnSpacing_givenEqualSplit_returnsConfiguredColumnSpacing() {
// Given
let strategy = PageCellSizingStrategy.equalSplit(
perRow: 2,
itemHeight: 100,
columnSpacing: 12,
rowSpacing: 8
)
// Then
XCTAssertEqual(strategy.columnSpacing, 12)
}
func test_rowSpacing_givenEqualSplit_returnsConfiguredRowSpacing() {
// Given
let strategy = PageCellSizingStrategy.equalSplit(
perRow: 2,
itemHeight: 100,
columnSpacing: 12,
rowSpacing: 8
)
// Then
XCTAssertEqual(strategy.rowSpacing, 8)
}
func test_columnAndRowSpacing_givenAdaptiveGrid_returnsConfiguredSpacings() {
// Given
let strategy = PageCellSizingStrategy.adaptiveGrid(
minColumns: 2,
maxColumns: 8,
minItemWidth: 100,
maxItemWidth: 200,
columnSpacing: 16,
rowSpacing: 24,
aspectRatio: CGSize(width: 1, height: 1)
)
// Then
XCTAssertEqual(strategy.columnSpacing, 16)
XCTAssertEqual(strategy.rowSpacing, 24)
}
func test_rowSpacing_givenFullWidthWithDefaultRowSpacing_returnsDefault() {
// Given
let strategy = PageCellSizingStrategy.fullWidth(estimatedHeight: 120, rowSpacing: 7)
// Then
XCTAssertEqual(strategy.rowSpacing, 7)
}
}
================================================
FILE: Tests/PageViewController/PageHeaderCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class PageHeaderCellViewModelTests: XCTestCase {
func test_init_storesHeadlineAndSubheadline() {
// When
let viewModel = PageHeaderCellViewModel(
headline: "Headline",
subheadline: "Subheadline"
)
// Then
XCTAssertEqual(viewModel.headline, "Headline")
XCTAssertEqual(viewModel.subheadline, "Subheadline")
}
}
================================================
FILE: Tests/RevealFile/RevealFileCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class RevealFileCoordinatorTests: XCTestCase {
func test_start_givenInvoked_invokesApplicationServiceOpenWithRevealFileURL() {
// Given
let openExpectation = XCTestExpectation(description: "ApplicationService.open is called.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = RevealFileCoordinator(
jotFileInfo: info,
applicationService: ApplicationServiceMock(
openProvider: { receivedURL in
XCTAssertEqual(receivedURL, RevealFileURL(jotFileInfo: info).toURL())
openExpectation.fulfill()
}
)
)
// When
coordinator.start()
// Then
wait(for: [openExpectation], timeout: 1)
}
func test_start_givenInvoked_invokesOnEnd() {
// Given
let onEndExpectation = XCTestExpectation(description: "RevealFileCoordinator.onEnd is invoked.")
let info = JotFile.Info(
url: URL(staticString: "file:///tmp/note.jot"),
name: "note",
modificationDate: nil,
ubiquitousInfo: nil
)
let coordinator = RevealFileCoordinator(
jotFileInfo: info,
applicationService: ApplicationServiceMock()
)
coordinator.onEnd = { onEndExpectation.fulfill() }
// When
coordinator.start()
// Then
wait(for: [onEndExpectation], timeout: 1)
}
}
================================================
FILE: Tests/SettingsPage/SettingsCoordinatorTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class SettingsCoordinatorTests: XCTestCase {
func test_start_givenInvoked_presentsNavigationControllerWithFactoryProducedRoot() {
// Given
let presentExpectation = XCTestExpectation(description: "Navigation.present is called.")
let madeViewController = UIViewController()
let navigation = Navigation.test(
presentViewControllerProvider: { viewController, animated in
MainActor.assumeIsolated {
// Then
XCTAssertTrue(viewController is UINavigationController)
let navigationController = viewController as? UINavigationController
XCTAssertEqual(navigationController?.viewControllers.first, madeViewController)
XCTAssertTrue(navigationController?.navigationBar.prefersLargeTitles ?? false)
XCTAssertTrue(animated)
presentExpectation.fulfill()
}
}
)
let coordinator = SettingsCoordinator(
navigation: navigation,
settingsViewControllerFactory: SettingsViewControllerFactoryMock(
makeProvider: { receivedCoordinator in
XCTAssertTrue(receivedCoordinator is SettingsCoordinator)
return madeViewController
}
)
)
// When
coordinator.start()
// Then
wait(for: [presentExpectation], timeout: 1)
}
func test_openExternalLink_givenURL_invokesNavigationOpenExternal() {
// Given
let openExternalExpectation = XCTestExpectation(description: "Navigation.openExternal is called.")
let expectedURL = URL(staticString: "https://example.com")
let navigation = Navigation.test(
openExternalURLProvider: { receivedURL in
// Then
XCTAssertEqual(receivedURL, expectedURL)
openExternalExpectation.fulfill()
}
)
let coordinator = SettingsCoordinator(
navigation: navigation,
settingsViewControllerFactory: SettingsViewControllerFactoryMock()
)
// When
coordinator.openExternalLink(url: expectedURL)
// Then
wait(for: [openExternalExpectation], timeout: 1)
}
func test_dismiss_givenInvoked_invokesNavigationDismissAnimated() {
// Given
let dismissExpectation = XCTestExpectation(description: "Navigation.dismiss is called.")
let navigation = Navigation.test(
dismissViewControllerProvider: { animated, _ in
// Then
XCTAssertTrue(animated)
dismissExpectation.fulfill()
}
)
let coordinator = SettingsCoordinator(
navigation: navigation,
settingsViewControllerFactory: SettingsViewControllerFactoryMock()
)
// When
coordinator.dismiss()
// Then
wait(for: [dismissExpectation], timeout: 1)
}
func test_dismiss_givenCompletion_invokesOnEnd() async {
// Given
let onEndExpectation = XCTestExpectation(description: "SettingsCoordinator.onEnd is called.")
let navigation = Navigation.test(
dismissViewControllerProvider: { _, completion in
completion?()
}
)
let coordinator = SettingsCoordinator(
navigation: navigation,
settingsViewControllerFactory: SettingsViewControllerFactoryMock()
)
coordinator.onEnd = { onEndExpectation.fulfill() }
// When
coordinator.dismiss()
// Then
await fulfillment(of: [onEndExpectation], timeout: 1)
}
}
================================================
FILE: Tests/SettingsPage/SettingsDropdownCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class SettingsDropdownCellViewModelTests: XCTestCase {
func test_init_storesNameCurrentAndOptionsFromBusinessModel() {
// Given
let light = SettingsDropdownBusinessModel.Option(label: "Light", value: "light")
let dark = SettingsDropdownBusinessModel.Option(label: "Dark", value: "dark")
let businessModel = SettingsDropdownBusinessModel(
name: "Appearance",
current: light,
options: [light, dark]
)
// When
let viewModel = SettingsDropdownCellViewModel(
settingsDropdown: businessModel,
onAction: { _ in }
)
// Then
XCTAssertEqual(viewModel.name, "Appearance")
XCTAssertEqual(viewModel.current, light)
XCTAssertEqual(viewModel.options, [light, dark])
}
func test_handleAction_givenTap_doesNotInvokeOnAction() async {
// Given
let onActionExpectation = XCTestExpectation(description: "onAction is not called for .tap")
onActionExpectation.isInverted = true
let option = SettingsDropdownBusinessModel.Option(label: "Light", value: "light")
let viewModel = SettingsDropdownCellViewModel(
settingsDropdown: SettingsDropdownBusinessModel(
name: "Appearance",
current: option,
options: [option]
),
onAction: { _ in onActionExpectation.fulfill() }
)
// When
viewModel.handle(action: .tap)
// Then
await fulfillment(of: [onActionExpectation], timeout: 0.05)
}
}
================================================
FILE: Tests/SettingsPage/SettingsExternalLinkCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class SettingsExternalLinkCellViewModelTests: XCTestCase {
func test_init_storesNameAndInfoFromBusinessModel() {
// Given
let businessModel = SettingsExternalLinkBusinessModel(name: "GitHub", info: "Open repository")
// When
let viewModel = SettingsExternalLinkCellViewModel(
settingsExternalLink: businessModel,
onAction: {}
)
// Then
XCTAssertEqual(viewModel.name, "GitHub")
XCTAssertEqual(viewModel.info, "Open repository")
}
func test_init_givenNilInfoBusinessModel_storesNilInfo() {
// Given
let businessModel = SettingsExternalLinkBusinessModel(name: "GitHub", info: nil)
// When
let viewModel = SettingsExternalLinkCellViewModel(
settingsExternalLink: businessModel,
onAction: {}
)
// Then
XCTAssertNil(viewModel.info)
}
func test_handleAction_givenTap_invokesOnAction() async {
// Given
let onActionExpectation = XCTestExpectation(description: "onAction is called.")
let viewModel = SettingsExternalLinkCellViewModel(
settingsExternalLink: SettingsExternalLinkBusinessModel(name: "GitHub", info: nil),
onAction: { onActionExpectation.fulfill() }
)
// When
viewModel.handle(action: .tap)
// Then
await fulfillment(of: [onActionExpectation], timeout: 0.2)
}
}
================================================
FILE: Tests/SettingsPage/SettingsInfoCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class SettingsInfoCellViewModelTests: XCTestCase {
func test_init_storesNameAndValueFromBusinessModel() {
// Given
let businessModel = SettingsInfoBusinessModel(name: "Version", value: "1.0.0")
// When
let viewModel = SettingsInfoCellViewModel(settingsInfo: businessModel)
// Then
XCTAssertEqual(viewModel.name, "Version")
XCTAssertEqual(viewModel.value, "1.0.0")
}
func test_handleAction_givenTap_doesNothing() {
// Given
let viewModel = SettingsInfoCellViewModel(
settingsInfo: SettingsInfoBusinessModel(name: "Version", value: "1.0.0")
)
// When
viewModel.handle(action: .tap)
// Then
XCTAssertEqual(viewModel.value, "1.0.0")
}
}
================================================
FILE: Tests/SettingsPage/SettingsRepositoryTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
final class SettingsRepositoryTests: XCTestCase {
func test_shouldShowEnableICloudButton_givenUbiquitousServiceDisabled_returnsTrue() {
// Given
let repository = SettingsRepository(
ubiquitousFileService: FileServiceMock(isEnabledProvider: { false }),
bundleService: BundleServiceMock(),
defaultsService: DefaultsServiceMock()
)
// Then
XCTAssertTrue(repository.shouldShowEnableICloudButton())
}
func test_shouldShowEnableICloudButton_givenUbiquitousServiceEnabled_returnsFalse() {
// Given
let repository = SettingsRepository(
ubiquitousFileService: FileServiceMock(isEnabledProvider: { true }),
bundleService: BundleServiceMock(),
defaultsService: DefaultsServiceMock()
)
// Then
XCTAssertFalse(repository.shouldShowEnableICloudButton())
}
func test_appVersion_givenBundleProvidesValue_returnsValue() {
// Given
let repository = SettingsRepository(
ubiquitousFileService: FileServiceMock(),
bundleService: BundleServiceMock(shortVersionStringProvider: { "1.2.3" }),
defaultsService: DefaultsServiceMock()
)
// Then
XCTAssertEqual(repository.appVersion(), "1.2.3")
}
func test_appVersion_givenBundleReturnsNil_returnsDash() {
// Given
let repository = SettingsRepository(
ubiquitousFileService: FileServiceMock(),
bundleService: BundleServiceMock(shortVersionStringProvider: { nil }),
defaultsService: DefaultsServiceMock()
)
// Then
XCTAssertEqual(repository.appVersion(), "-")
}
func test_userInterfaceStyle_givenStoredRawValue_emitsMatchingStyle() async throws {
// Given
let defaultsServiceMock = DefaultsServiceMock(
initialValues: [DefaultsKey.userInterfaceStyle.description: UIUserInterfaceStyle.dark.rawValue]
)
let repository = SettingsRepository(
ubiquitousFileService: FileServiceMock(),
bundleService: BundleServiceMock(),
defaultsService: defaultsServiceMock
)
// When
var iterator = repository.userInterfaceStyle().makeAsyncIterator()
let first = try await XCTUnwrapAsync(await iterator.next())
// Then
XCTAssertEqual(first, .dark)
}
func test_userInterfaceStyle_givenNoStoredValue_emitsUnspecified() async throws {
// Given
let repository = SettingsRepository(
ubiquitousFileService: FileServiceMock(),
bundleService: BundleServiceMock(),
defaultsService: DefaultsServiceMock()
)
// When
var iterator = repository.userInterfaceStyle().makeAsyncIterator()
let first = try await XCTUnwrapAsync(await iterator.next())
// Then
XCTAssertEqual(first, .unspecified)
}
func test_updateUserInterfaceStyle_persistsRawValueInDefaults() {
// Given
let defaultsServiceMock = DefaultsServiceMock()
let repository = SettingsRepository(
ubiquitousFileService: FileServiceMock(),
bundleService: BundleServiceMock(),
defaultsService: defaultsServiceMock
)
// When
repository.updateUserInterfaceStyle(.light)
// Then
XCTAssertEqual(defaultsServiceMock.getValue(.userInterfaceStyle), UIUserInterfaceStyle.light.rawValue)
}
}
private func XCTUnwrapAsync(
_ expression: @autoclosure () async throws -> T?,
file: StaticString = #filePath,
line: UInt = #line
) async throws -> T {
let value = try await expression()
return try XCTUnwrap(value, file: file, line: line)
}
================================================
FILE: Tests/SettingsPage/SettingsToggleCellViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
@MainActor
final class SettingsToggleCellViewModelTests: XCTestCase {
func test_init_storesNameAndIsOnFromBusinessModel() {
// Given
let businessModel = SettingsToggleBusinessModel(name: "iCloud", isOn: true)
// When
let viewModel = SettingsToggleCellViewModel(settingsToggle: businessModel)
// Then
XCTAssertEqual(viewModel.name, "iCloud")
XCTAssertTrue(viewModel.isOn)
}
func test_handleAction_givenTap_doesNothing() {
// Given
let viewModel = SettingsToggleCellViewModel(
settingsToggle: SettingsToggleBusinessModel(name: "iCloud", isOn: false)
)
// When
viewModel.handle(action: .tap)
// Then
XCTAssertFalse(viewModel.isOn)
}
}
================================================
FILE: Tests/SettingsPage/SettingsViewModelTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class SettingsViewModelTests: XCTestCase {
func test_rightNavigationItems_givenInit_yieldsDismissSymbol() async throws {
// Given
let viewModel = SettingsViewModel(
repository: SettingsRepositoryMock(),
coordinator: SettingsCoordinatorMock()
)
// When
let items = try await firstValue(of: viewModel.rightNavigationItems)
// Then
XCTAssertEqual(items.count, 1)
guard case let .symbol(systemImageName, _) = items[0] else {
XCTFail("Expected .symbol")
return
}
XCTAssertEqual(systemImageName, "xmark")
}
func test_rightNavigationItem_givenTap_invokesCoordinatorDismiss() async throws {
// Given
let dismissExpectation = XCTestExpectation(description: "SettingsCoordinatorMock.dismiss is called.")
let coordinatorMock = SettingsCoordinatorMock(
dismissProvider: { dismissExpectation.fulfill() }
)
let viewModel = SettingsViewModel(
repository: SettingsRepositoryMock(),
coordinator: coordinatorMock
)
// When
let items = try await firstValue(of: viewModel.rightNavigationItems)
guard case let .symbol(_, onAction) = items[0] else {
XCTFail("Expected .symbol")
return
}
onAction()
// Then
await fulfillment(of: [dismissExpectation], timeout: 1)
}
func test_didLoad_givenShouldNotShowICloud_yieldsThreeItems() async throws {
// Given
let userInterfaceStyleStream = AsyncStream { continuation in
continuation.yield(.unspecified)
continuation.finish()
}
let viewModel = SettingsViewModel(
repository: SettingsRepositoryMock(
shouldShowEnableICloudButtonProvider: { false },
appVersionProvider: { "1.2.3" },
userInterfaceStyleProvider: { userInterfaceStyleStream }
),
coordinator: SettingsCoordinatorMock()
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
// Then
XCTAssertEqual(items.count, 3)
XCTAssertNotNil(items[0].id as? SettingsDropdownBusinessModel)
XCTAssertNotNil(items[1].id as? SettingsExternalLinkBusinessModel)
XCTAssertNotNil(items[2].id as? SettingsInfoBusinessModel)
let info = try XCTUnwrap(items[2].id as? SettingsInfoBusinessModel)
XCTAssertEqual(info.value, "1.2.3")
}
func test_didLoad_givenShouldShowICloud_yieldsFourItemsWithICloudLink() async throws {
// Given
let userInterfaceStyleStream = AsyncStream { continuation in
continuation.yield(.dark)
continuation.finish()
}
let viewModel = SettingsViewModel(
repository: SettingsRepositoryMock(
shouldShowEnableICloudButtonProvider: { true },
userInterfaceStyleProvider: { userInterfaceStyleStream }
),
coordinator: SettingsCoordinatorMock()
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
// Then
XCTAssertEqual(items.count, 4)
XCTAssertNotNil(items[1].id as? SettingsExternalLinkBusinessModel)
XCTAssertNotNil(items[2].id as? SettingsExternalLinkBusinessModel)
}
func test_didLoad_givenDarkStyle_yieldsDropdownWithDarkAsCurrent() async throws {
// Given
let userInterfaceStyleStream = AsyncStream { continuation in
continuation.yield(.dark)
continuation.finish()
}
let viewModel = SettingsViewModel(
repository: SettingsRepositoryMock(
userInterfaceStyleProvider: { userInterfaceStyleStream }
),
coordinator: SettingsCoordinatorMock()
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
// Then
let dropdown = try XCTUnwrap(items[0].id as? SettingsDropdownBusinessModel)
XCTAssertEqual(dropdown.current.value as? UIUserInterfaceStyle, .dark)
XCTAssertEqual(dropdown.options.count, 3)
}
func test_didLoadExternalLinkTap_givenICloudVisible_invokesOpenExternalLinkWithICloudURL() async throws {
// Given
let openExternalLinkExpectation =
XCTestExpectation(description: "SettingsCoordinatorMock.openExternalLink is called.")
let userInterfaceStyleStream = AsyncStream { continuation in
continuation.yield(.unspecified)
continuation.finish()
}
let coordinatorMock = SettingsCoordinatorMock(
openExternalLinkProvider: { receivedURL in
// Then
XCTAssertEqual(receivedURL, EnableICloudSupportURL().toURL())
openExternalLinkExpectation.fulfill()
}
)
let viewModel = SettingsViewModel(
repository: SettingsRepositoryMock(
shouldShowEnableICloudButtonProvider: { true },
userInterfaceStyleProvider: { userInterfaceStyleStream }
),
coordinator: coordinatorMock
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
items[1].handleAction(.tap)
// Then
await fulfillment(of: [openExternalLinkExpectation], timeout: 1)
}
func test_didLoadExternalLinkTap_givenGithubLink_invokesOpenExternalLinkWithGithubURL() async throws {
// Given
let openExternalLinkExpectation =
XCTestExpectation(description: "SettingsCoordinatorMock.openExternalLink is called.")
let userInterfaceStyleStream = AsyncStream { continuation in
continuation.yield(.unspecified)
continuation.finish()
}
let coordinatorMock = SettingsCoordinatorMock(
openExternalLinkProvider: { receivedURL in
// Then
XCTAssertEqual(receivedURL, JottreGithubURL().toURL())
openExternalLinkExpectation.fulfill()
}
)
let viewModel = SettingsViewModel(
repository: SettingsRepositoryMock(
shouldShowEnableICloudButtonProvider: { false },
userInterfaceStyleProvider: { userInterfaceStyleStream }
),
coordinator: coordinatorMock
)
// When
viewModel.didLoad()
let items = try await firstValue(of: viewModel.items)
items[1].handleAction(.tap)
// Then
await fulfillment(of: [openExternalLinkExpectation], timeout: 1)
}
}
@MainActor
private func firstValue(
of sequence: S
) async throws -> S.Element where S.Element: Sendable {
var iterator = sequence.makeAsyncIterator()
guard let value = try await iterator.next() else {
throw NSError(domain: "SettingsViewModelTests", code: 0)
}
return value
}
================================================
FILE: Tests/Utilities/Array+safeIndexTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class ArraySafeIndexTests: XCTestCase {
func test_safeSubscript_givenIndexInBounds_returnsElement() {
// Given
let array = [10, 20, 30]
// When
let element = array[safe: 1]
// Then
XCTAssertEqual(element, 20)
}
func test_safeSubscript_givenIndexOutOfBounds_returnsNil() {
// Given
let array = [10, 20, 30]
// When
let element = array[safe: 5]
// Then
XCTAssertNil(element)
}
func test_safeSubscript_givenEmptyArray_returnsNil() {
// Given
let array: [Int] = []
// When
let element = array[safe: 0]
// Then
XCTAssertNil(element)
}
}
================================================
FILE: Tests/Utilities/AsyncSequenceDebounceTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class AsyncSequenceDebounceTests: XCTestCase {
func test_debounce_givenSingleValue_yieldsValueAfterInterval() async throws {
// Given
let stream = AsyncStream { continuation in
continuation.yield(1)
continuation.finish()
}
// When
var iterator = stream.debounce(for: 0.05).makeAsyncIterator()
let first = try await XCTUnwrapAsync(await iterator.next())
let second = await iterator.next()
// Then
XCTAssertEqual(first, 1)
XCTAssertNil(second)
}
func test_debounce_givenBurstOfValues_yieldsOnlyLastValue() async throws {
// Given
let stream = AsyncStream { continuation in
continuation.yield(1)
continuation.yield(2)
continuation.yield(3)
continuation.finish()
}
// When
var values: [Int] = []
for await value in stream.debounce(for: 0.05) {
values.append(value)
}
// Then
XCTAssertEqual(values, [3])
}
}
private func XCTUnwrapAsync(
_ expression: @autoclosure () async throws -> T?,
file: StaticString = #filePath,
line: UInt = #line
) async throws -> T {
let value = try await expression()
return try XCTUnwrap(value, file: file, line: line)
}
================================================
FILE: Tests/Utilities/AsyncSequenceToAsyncThrowingStreamTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import XCTest
@testable import Jottre
final class AsyncSequenceToAsyncThrowingStreamTests: XCTestCase {
func test_toAsyncThrowingStream_givenFiniteSequence_yieldsAllElementsThenFinishes() async throws {
// Given
let source = AsyncStream { continuation in
continuation.yield(1)
continuation.yield(2)
continuation.yield(3)
continuation.finish()
}
// When
var collected: [Int] = []
for try await value in source.toAsyncThrowingStream() {
collected.append(value)
}
// Then
XCTAssertEqual(collected, [1, 2, 3])
}
func test_toAsyncStream_givenFiniteSequence_yieldsAllElementsThenFinishes() async {
// Given
let source = AsyncStream { continuation in
continuation.yield(10)
continuation.yield(20)
continuation.finish()
}
// When
var collected: [Int] = []
for await value in source.toAsyncStream() {
collected.append(value)
}
// Then
XCTAssertEqual(collected, [10, 20])
}
func test_toAsyncThrowingStream_givenThrowingUpstream_propagatesError() async {
// Given
struct DummyError: Error, Equatable {}
let source = AsyncThrowingStream { continuation in
continuation.yield(1)
continuation.finish(throwing: DummyError())
}
// When / Then
do {
for try await _ in source.toAsyncThrowingStream() {
/* no-op */
}
XCTFail("Expected the upstream error to propagate.")
} catch is DummyError {
// Expected
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}
================================================
FILE: Tests/Utilities/NSLayoutConstraintWithPriorityTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
@MainActor
final class NSLayoutConstraintWithPriorityTests: XCTestCase {
func test_withPriority_setsPriorityAndReturnsSameConstraint() {
// Given
let view = UIView()
let constraint = view.widthAnchor.constraint(equalToConstant: 100)
// When
let returned = constraint.withPriority(.defaultLow)
// Then
XCTAssertEqual(constraint.priority, .defaultLow)
XCTAssertTrue(returned === constraint)
}
func test_withPriority_givenChainedCalls_appliesLastValue() {
// Given
let view = UIView()
let constraint = view.widthAnchor.constraint(equalToConstant: 100)
// When
_ = constraint.withPriority(.defaultLow).withPriority(.required)
// Then
XCTAssertEqual(constraint.priority, .required)
}
}
================================================
FILE: Tests/Utilities/UIColorAdaptiveBlackWhiteTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
final class UIColorAdaptiveBlackWhiteTests: XCTestCase {
func test_adaptiveBlackWhite_givenLightUserInterfaceStyle_resolvesToWhite() {
// Given
let lightTraits = UITraitCollection(userInterfaceStyle: .light)
// When
let resolved = UIColor.adaptiveBlackWhite.resolvedColor(with: lightTraits)
// Then
XCTAssertEqual(resolved, UIColor.white)
}
func test_adaptiveBlackWhite_givenDarkUserInterfaceStyle_resolvesToBlack() {
// Given
let darkTraits = UITraitCollection(userInterfaceStyle: .dark)
// When
let resolved = UIColor.adaptiveBlackWhite.resolvedColor(with: darkTraits)
// Then
XCTAssertEqual(resolved, UIColor.black)
}
}
================================================
FILE: Tests/Utilities/UIFontPreferredFontTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
final class UIFontPreferredFontTests: XCTestCase {
func test_preferredFont_givenBodyTextStyleAndBoldWeight_returnsScaledBoldFont() {
// When
let font = UIFont.preferredFont(forTextStyle: .body, weight: .bold)
// Then
let traits = font.fontDescriptor.symbolicTraits
XCTAssertTrue(traits.contains(.traitBold))
XCTAssertGreaterThan(font.pointSize, 0)
}
func test_preferredFont_givenLargerTextStyle_producesLargerOrEqualPointSize() {
// When
let captionFont = UIFont.preferredFont(forTextStyle: .caption2, weight: .regular)
let titleFont = UIFont.preferredFont(forTextStyle: .title1, weight: .regular)
// Then
XCTAssertGreaterThan(titleFont.pointSize, captionFont.pointSize)
}
}
================================================
FILE: Tests/Utilities/UITraitCollectionHasRenderingChangeTests.swift
================================================
/*
Jottre: Minimalistic jotting for iPhone, iPad and Mac.
Copyright (C) 2021-2026 Anton Lorani
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import UIKit
import XCTest
@testable import Jottre
final class UITraitCollectionHasRenderingChangeTests: XCTestCase {
func test_hasRenderingChange_givenIdenticalTraits_returnsFalse() {
// Given
let traits = UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: .light),
UITraitCollection(displayScale: 2.0),
])
// When
let result = traits.hasRenderingChange(comparedTo: traits)
// Then
XCTAssertFalse(result)
}
func test_hasRenderingChange_givenNilPrevious_returnsTrue() {
// Given
let traits = UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: .light),
UITraitCollection(displayScale: 2.0),
])
// When
let result = traits.hasRenderingChange(comparedTo: nil)
// Then
XCTAssertTrue(result)
}
func test_hasRenderingChange_givenDifferingUserInterfaceStyle_returnsTrue() {
// Given
let lightTraits = UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: .light),
UITraitCollection(displayScale: 2.0),
])
let darkTraits = UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: 2.0),
])
// When
let result = lightTraits.hasRenderingChange(comparedTo: darkTraits)
// Then
XCTAssertTrue(result)
}
func test_hasRenderingChange_givenDifferingDisplayScale_returnsTrue() {
// Given
let twoXTraits = UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: .light),
UITraitCollection(displayScale: 2.0),
])
let threeXTraits = UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: .light),
UITraitCollection(displayScale: 3.0),
])
// When
let result = twoXTraits.hasRenderingChange(comparedTo: threeXTraits)
// Then
XCTAssertTrue(result)
}
}
================================================
FILE: fastlane/.gitignore
================================================
report.xml
build/
================================================
FILE: fastlane/Appfile
================================================
# Jottre: Minimalistic jotting for iPhone, iPad and Mac.
# Copyright (C) 2021-2026 Anton Lorani
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
app_identifier("com.antonlorani.jottre")
================================================
FILE: fastlane/Fastfile
================================================
# frozen_string_literal: true
# Jottre: Minimalistic jotting for iPhone, iPad and Mac.
# Copyright (C) 2021-2026 Anton Lorani
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
require_relative 'versioning'
require_relative 'appstore_metadata'
require_relative 'export_screenshots'
DEFAULT_SCREENSHOTS_DIR = File.expand_path('appstoreconnect/screenshots', __dir__)
default_platform(:ios)
SCHEME = 'Jottre'
INFO_PLIST = 'Resources/Info.plist'
APP_IDENTIFIER = 'com.antonlorani.jottre'
TEAM_ID = 'Y78RPE9KK3'
APPLE_DISTRIBUTION_IDENTITY = 'Apple Distribution'
APP_STORE_PROFILE = "match AppStore #{APP_IDENTIFIER}"
CATALYST_APP_STORE_PROFILE = "match AppStore #{APP_IDENTIFIER} catalyst"
NO_SIGN_XCARGS = "CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO"
def make_options(options = {})
api_key = app_store_connect_api_key(
key_id: ENV.fetch('APP_STORE_CONNECT_API_KEY_ID'),
issuer_id: ENV.fetch('APP_STORE_CONNECT_API_ISSUER_ID'),
key_content: ENV.fetch('APP_STORE_CONNECT_API_KEY_CONTENT'),
is_key_content_base64: true
)
{
api_key: api_key
}.merge(options)
end
def release_build_number(platform:)
platform_suffix = platform == 'osx' ? '2' : '1'
build_number = "#{Time.now.utc.strftime('%Y%m%d%H%M%S%L')}#{platform_suffix}"
UI.message("Using timestamp build number #{build_number}")
build_number
end
def configure_manual_signing(identity:, provisioning_profile:)
update_code_signing_settings(
use_automatic_signing: false,
path: 'Jottre.xcodeproj',
targets: ['Jottre'],
team_id: TEAM_ID,
code_sign_identity: identity,
profile_name: provisioning_profile
)
end
def manual_signing_export_options(provisioning_profile:, bundle_id: APP_IDENTIFIER)
{
signingStyle: 'manual',
teamID: TEAM_ID,
provisioningProfiles: {
bundle_id => provisioning_profile
}
}
end
def upload_app_store_metadata(platform:, api_key:)
output_dir = File.expand_path("build/asc-metadata-#{platform}", __dir__)
metadata_path, screenshots_path = AppStoreMetadata.stage(
platform: platform,
output_dir: output_dir
)
upload_to_app_store(
api_key: api_key,
platform: platform == :mac ? 'osx' : 'ios',
metadata_path: metadata_path,
screenshots_path: screenshots_path,
skip_binary_upload: true,
overwrite_screenshots: true,
submit_for_review: false,
automatic_release: false,
precheck_include_in_app_purchases: false,
force: true
)
end
desc 'Export App Store screenshots from a Sketch file'
lane :export_screenshots do |options|
unless options[:sketch_file]
UI.user_error!('sketch_file is required (e.g. fastlane export_screenshots sketch_file:path/to/file.sketch)')
end
sketch_file = File.expand_path(options[:sketch_file])
output_dir = options[:output_dir] ? File.expand_path(options[:output_dir]) : DEFAULT_SCREENSHOTS_DIR
ExportScreenshots.export(
sketch_file: sketch_file,
output_dir: output_dir
)
end
desc 'Bump version from merge commit and push tag'
lane :bump_version do
tag = Versioning.latest_tag
UI.user_error!("HEAD is already tagged as #{tag}.") if tag && Versioning.head_tagged?(tag)
bump_strategy = Versioning.bump_strategy(since_tag: tag)
if bump_strategy
current = tag || get_info_plist_value(path: INFO_PLIST, key: 'CFBundleShortVersionString')
new_version = Versioning.bump_version(
current: current,
bump_strategy: bump_strategy
)
UI.message("#{current} → #{new_version} (#{bump_strategy} version bump)")
add_git_tag(tag: new_version)
push_git_tags
else
UI.message('No version marker found; keeping the current marketing version.')
end
end
platform :ios do
desc 'Generate the Xcode project using XcodeGen'
lane :generate_project do
sh('xcodegen generate --spec ../project.yml')
end
desc 'Run unit tests'
lane :test do
generate_project
run_tests(
scheme: SCHEME,
destination: 'platform=iOS Simulator,name=iPhone 17',
xcargs: NO_SIGN_XCARGS,
output_directory: 'build'
)
end
desc 'Verify iOS/iPadOS debug build'
lane :build_debug do
generate_project
gym(
scheme: SCHEME,
configuration: 'Debug',
destination: 'generic/platform=iOS',
xcargs: NO_SIGN_XCARGS,
skip_package_ipa: true,
output_directory: 'build'
)
end
desc 'Verify iOS/iPadOS release build'
lane :build_release do
generate_project
gym(
scheme: SCHEME,
configuration: 'Release',
destination: 'generic/platform=iOS',
xcargs: NO_SIGN_XCARGS,
skip_package_ipa: true,
output_directory: 'build'
)
end
desc 'Generate/sync development and App Store provisioning profiles for iOS/iPadOS'
lane :sync_profiles do |options|
options = make_options(options)
readonly = options.fetch(:readonly, true)
match(
type: 'development',
platform: 'ios',
api_key: options[:api_key],
readonly: readonly
)
match(
type: 'appstore',
platform: 'ios',
api_key: options[:api_key],
readonly: readonly
)
end
desc 'Build and upload iOS/iPadOS release to App Store Connect'
lane :distribute do |options|
options = make_options(options)
setup_ci
match(
type: 'appstore',
platform: 'ios',
api_key: options[:api_key],
readonly: true
)
version = Versioning.latest_tag
UI.user_error!('No version tag found') unless version
build_number = release_build_number(platform: 'ios')
generate_project
set_info_plist_value(
path: INFO_PLIST,
key: 'CFBundleShortVersionString',
value: version
)
set_info_plist_value(
path: INFO_PLIST,
key: 'CFBundleVersion',
value: build_number
)
configure_manual_signing(
identity: APPLE_DISTRIBUTION_IDENTITY,
provisioning_profile: APP_STORE_PROFILE
)
gym(
scheme: SCHEME,
configuration: 'Release',
destination: 'generic/platform=iOS',
export_method: 'app-store',
export_options: manual_signing_export_options(
provisioning_profile: APP_STORE_PROFILE
)
)
upload_to_app_store(
api_key: options[:api_key],
submit_for_review: false,
automatic_release: false,
precheck_include_in_app_purchases: false,
skip_metadata: true,
skip_screenshots: true
)
distribution_tag = "#{version}.#{build_number}+ios"
add_git_tag(tag: distribution_tag)
push_git_tags(tag: distribution_tag)
end
desc 'Upload App Store metadata + iPhone/iPad screenshots'
lane :upload_metadata do |options|
options = make_options(options)
upload_app_store_metadata(platform: :ios, api_key: options[:api_key])
end
end
platform :mac do
desc 'Generate the Xcode project using XcodeGen'
lane :generate_project do
sh('xcodegen generate --spec ../project.yml')
end
desc 'Verify macOS debug build'
lane :build_debug do
generate_project
gym(
scheme: SCHEME,
configuration: 'Debug',
destination: 'platform=macOS,variant=Mac Catalyst',
xcargs: NO_SIGN_XCARGS,
skip_package_ipa: true,
output_directory: 'build'
)
end
desc 'Verify macOS release build'
lane :build_release do
generate_project
gym(
scheme: SCHEME,
configuration: 'Release',
destination: 'platform=macOS,variant=Mac Catalyst',
xcargs: NO_SIGN_XCARGS,
skip_package_ipa: true,
output_directory: 'build'
)
end
desc 'Generate/sync development and App Store provisioning profiles for macOS (Mac Catalyst)'
lane :sync_profiles do |options|
options = make_options(options)
readonly = options.fetch(:readonly, true)
force = options.fetch(:force, false)
match(
type: 'development',
platform: 'catalyst',
api_key: options[:api_key],
readonly: readonly
)
match(
type: 'appstore',
platform: 'catalyst',
additional_cert_types: ['mac_installer_distribution'],
api_key: options[:api_key],
readonly: readonly
)
end
desc 'Build and upload macOS release to App Store Connect'
lane :distribute do |options|
options = make_options(options)
setup_ci
match(
type: 'appstore',
platform: 'catalyst',
additional_cert_types: ['mac_installer_distribution'],
api_key: options[:api_key],
readonly: true
)
version = Versioning.latest_tag
UI.user_error!('No version tag found') unless version
build_number = release_build_number(platform: 'osx')
generate_project
set_info_plist_value(
path: INFO_PLIST,
key: 'CFBundleShortVersionString',
value: version
)
set_info_plist_value(
path: INFO_PLIST,
key: 'CFBundleVersion',
value: build_number
)
configure_manual_signing(
identity: APPLE_DISTRIBUTION_IDENTITY,
provisioning_profile: CATALYST_APP_STORE_PROFILE
)
update_code_signing_settings(
use_automatic_signing: false,
path: 'Jottre.xcodeproj',
targets: ['AppKitPlugin'],
team_id: TEAM_ID,
code_sign_identity: APPLE_DISTRIBUTION_IDENTITY
)
pkg_path = gym(
scheme: SCHEME,
configuration: 'Release',
destination: 'platform=macOS,variant=Mac Catalyst',
catalyst_platform: 'macos',
export_method: 'app-store',
export_options: manual_signing_export_options(
provisioning_profile: CATALYST_APP_STORE_PROFILE
),
output_directory: 'build'
)
UI.user_error!("Expected gym to export a .pkg, got #{pkg_path || 'nil'}") unless pkg_path&.end_with?('.pkg')
upload_to_app_store(
api_key: options[:api_key],
pkg: pkg_path,
submit_for_review: false,
automatic_release: false,
precheck_include_in_app_purchases: false,
skip_metadata: true,
skip_screenshots: true
)
distribution_tag = "#{version}.#{build_number}+catalyst"
add_git_tag(tag: distribution_tag)
push_git_tags(tag: distribution_tag)
end
desc 'Upload App Store metadata + Mac screenshots'
lane :upload_metadata do |options|
options = make_options(options)
upload_app_store_metadata(platform: :mac, api_key: options[:api_key])
end
end
================================================
FILE: fastlane/Matchfile
================================================
# Jottre: Minimalistic jotting for iPhone, iPad and Mac.
# Copyright (C) 2021-2026 Anton Lorani
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
git_url("https://github.com/antonlorani/apple-provisioning")
storage_mode("git")
platform("ios")
app_identifier(["com.antonlorani.jottre"])
================================================
FILE: fastlane/README.md
================================================
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
### export_screenshots
```sh
[bundle exec] fastlane export_screenshots
```
Export App Store screenshots from a Sketch file
### bump_version
```sh
[bundle exec] fastlane bump_version
```
Bump version from merge commit and push tag
----
## iOS
### ios generate_project
```sh
[bundle exec] fastlane ios generate_project
```
Generate the Xcode project using XcodeGen
### ios test
```sh
[bundle exec] fastlane ios test
```
Run unit tests
### ios build_debug
```sh
[bundle exec] fastlane ios build_debug
```
Verify iOS/iPadOS debug build
### ios build_release
```sh
[bundle exec] fastlane ios build_release
```
Verify iOS/iPadOS release build
### ios sync_profiles
```sh
[bundle exec] fastlane ios sync_profiles
```
Generate/sync development and App Store provisioning profiles for iOS/iPadOS
### ios distribute
```sh
[bundle exec] fastlane ios distribute
```
Build and upload iOS/iPadOS release to App Store Connect
### ios upload_metadata
```sh
[bundle exec] fastlane ios upload_metadata
```
Upload App Store metadata + iPhone/iPad screenshots
----
## Mac
### mac generate_project
```sh
[bundle exec] fastlane mac generate_project
```
Generate the Xcode project using XcodeGen
### mac build_debug
```sh
[bundle exec] fastlane mac build_debug
```
Verify macOS debug build
### mac build_release
```sh
[bundle exec] fastlane mac build_release
```
Verify macOS release build
### mac sync_profiles
```sh
[bundle exec] fastlane mac sync_profiles
```
Generate/sync development and App Store provisioning profiles for macOS (Mac Catalyst)
### mac distribute
```sh
[bundle exec] fastlane mac distribute
```
Build and upload macOS release to App Store Connect
### mac upload_metadata
```sh
[bundle exec] fastlane mac upload_metadata
```
Upload App Store metadata + Mac screenshots
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
================================================
FILE: fastlane/appstore_metadata.rb
================================================
# frozen_string_literal: true
# Jottre: Minimalistic jotting for iPhone, iPad and Mac.
# Copyright (C) 2021-2026 Anton Lorani
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
require 'json'
require 'fileutils'
module AppStoreMetadata
ROOT = File.join(__dir__, 'appstoreconnect')
METADATA_FILE = File.join(ROOT, 'metadata.json')
SCREENSHOTS_ROOT = File.join(ROOT, 'screenshots')
ASC_LOCALES = {
'en' => %w[en-US en-AU en-GB en-CA],
'de' => %w[de-DE],
'es' => %w[es-ES es-MX],
'fr' => %w[fr-FR fr-CA],
'hi' => %w[hi],
'ko' => %w[ko]
}.freeze
TEXT_FIELDS = %w[
name subtitle description keywords promotional_text release_notes
marketing_url privacy_url support_url
].freeze
def self.stage(platform:, output_dir:)
platform_key = platform.to_s
metadata = load_metadata
metadata_dir = File.join(output_dir, 'metadata')
screenshots_dir = File.join(output_dir, 'screenshots')
FileUtils.rm_rf(output_dir)
FileUtils.mkdir_p([metadata_dir, screenshots_dir])
ASC_LOCALES.each do |short, asc_locales|
locale_data = metadata[short] || {}
asc_locales.each do |asc|
write_metadata(locale_data, File.join(metadata_dir, asc))
copy_screenshots(short, platform_key, locale_data, File.join(screenshots_dir, asc))
end
end
[metadata_dir, screenshots_dir]
end
def self.load_metadata
raise "Missing metadata file at #{METADATA_FILE}" unless File.exist?(METADATA_FILE)
JSON.parse(File.read(METADATA_FILE))
end
def self.write_metadata(locale_data, dir)
FileUtils.mkdir_p(dir)
TEXT_FIELDS.each do |field|
value = locale_data[field]
next if value.nil? || value.to_s.strip.empty?
File.write(File.join(dir, "#{field}.txt"), value)
end
end
def self.copy_screenshots(short_locale, platform_key, locale_data, dir)
FileUtils.mkdir_p(dir)
platform_screenshots = locale_data.fetch('screenshots') do
raise "Missing 'screenshots' for locale '#{short_locale}' in #{METADATA_FILE}"
end
devices = platform_screenshots.fetch(platform_key) do
raise "Missing screenshots for platform '#{platform_key}' in locale '#{short_locale}'"
end
devices.each do |device, files|
files.each_with_index do |name, idx|
src = File.join(SCREENSHOTS_ROOT, short_locale, device, name)
raise "Missing screenshot file: #{src}" unless File.exist?(src)
base = File.basename(name, '.png')
dst = File.join(dir, format('%s_%02d_%s.png', device: device, idx: idx, name: base))
FileUtils.cp(src, dst)
end
end
end
end
================================================
FILE: fastlane/appstoreconnect/metadata.json
================================================
{
"en": {
"name": "Jottre",
"subtitle": "Minimalistic handwriting",
"description": "Jottre is a simple canvas for handwritten notes and sketches. Write freely with Apple Pencil on iPad, or use your finger on iPhone. Notes sync automatically across your iPhone, iPad, and Mac with iCloud. When you're ready to share, export as PDF, JPG, or PNG.",
"keywords": "simple notes,jot,notepad,sketchpad,minimal,whiteboard,quick notes,diagram,canvas,jotter,paper,draw",
"promotional_text": "Simple and minimalistic jotting.",
"release_notes": "Jottre 2.0 builds on what worked well, with thoughtful improvements throughout.",
"marketing_url": "https://github.com/antonlorani/jottre",
"privacy_url": "https://github.com/antonlorani/jottre/blob/master/PRIVACY_POLICY.md",
"support_url": "https://github.com/antonlorani/jottre/issues",
"screenshots": {
"ios": {
"iphone": ["jotting.png", "icloud.png", "darkmode.png", "share.png"],
"ipad": ["jotting.png", "applepencil.png", "icloud.png", "darkmode.png", "share.png"]
},
"mac": {
"mac": ["jotting.png", "icloud.png", "darkmode.png", "share.png"]
}
}
},
"de": {
"name": "Jottre",
"subtitle": "Minimalistische Notizen",
"description": "Jottre ist ein freier Raum für handgeschriebene Notizen und Skizzen. Schreib mit dem Apple Pencil auf dem iPad oder mit dem Finger auf dem iPhone. Deine Notizen werden automatisch über iCloud auf iPhone, iPad und Mac synchronisiert. Wenn du bereit bist zu teilen, exportiere sie als PDF, JPG oder PNG.",
"keywords": "notizen,notizblock,skizzenblock,minimal,whiteboard,schnellnotizen,diagramm,notizheft,papier,zeichnen",
"promotional_text": "Einfach und minimalistische Notizen",
"release_notes": "Jottre 2.0 baut auf dem auf, was gut funktioniert hat – mit durchdachten Verbesserungen in allen Bereichen.",
"marketing_url": "https://github.com/antonlorani/jottre",
"privacy_url": "https://github.com/antonlorani/jottre/blob/master/PRIVACY_POLICY.md",
"support_url": "https://github.com/antonlorani/jottre/issues",
"screenshots": {
"ios": {
"iphone": ["jotting.png", "icloud.png", "darkmode.png", "share.png"],
"ipad": ["jotting.png", "applepencil.png", "icloud.png", "darkmode.png", "share.png"]
},
"mac": {
"mac": ["jotting.png", "icloud.png", "darkmode.png", "share.png"]
}
}
},
"es": {
"name": "Jottre",
"subtitle": "Escritura minimalista",
"description": "Jottre es un lienzo sencillo para notas y bocetos a mano alzada. Escribe con total libertad usando el Apple Pencil en el iPad, o con el dedo en el iPhone. Tus notas se sincronizan automáticamente entre iPhone, iPad y Mac mediante iCloud. Cuando quieras compartir, expórtalas en PDF, JPG o PNG.",
"keywords": "notas,apuntes,bloc,cuaderno,minimal,pizarra,boceto,dibujar,lienzo,papel,esquema,rápidas",
"promotional_text": "Apuntes sencillos y minimalistas.",
"release_notes": "Jottre 2.0 mantiene lo que ya funcionaba e incorpora mejoras pensadas con detalle.",
"marketing_url": "https://github.com/antonlorani/jottre",
"privacy_url": "https://github.com/antonlorani/jottre/blob/master/PRIVACY_POLICY.md",
"support_url": "https://github.com/antonlorani/jottre/issues",
"screenshots": {
"ios": {
"iphone": ["jotting.png", "icloud.png", "darkmode.png", "share.png"],
"ipad": ["jotting.png", "applepencil.png", "icloud.png", "darkmode.png", "share.png"]
},
"mac": {
"mac": ["jotting.png", "icloud.png", "darkmode.png", "share.png"]
}
}
},
"fr": {
"name": "Jottre",
"subtitle": "Écriture minimaliste",
"description": "Jottre, c'est une page blanche pensée pour les notes manuscrites et les croquis. Écrivez librement avec l'Apple Pencil sur iPad, ou du bout du doigt sur iPhone. Vos notes se synchronisent automatiquement entre iPhone, iPad et Mac via iCloud. Au moment de partager, exportez-les en PDF, JPG ou PNG.",
"keywords": "notes,carnet,bloc-notes,croquis,minimal,tableau blanc,prise de notes,schéma,dessin,papier,esquisse",
"promotional_text": "Des notes simples et épurées.",
"release_notes": "Jottre 2.0 conserve ce qui marchait et y ajoute des améliorations pensées avec soin.",
"marketing_url": "https://github.com/antonlorani/jottre",
"privacy_url": "https://github.com/antonlorani/jottre/blob/master/PRIVACY_POLICY.md",
"support_url": "https://github.com/antonlorani/jottre/issues",
"screenshots": {
"ios": {
"iphone": ["jotting.png", "icloud.png", "darkmode.png", "share.png"],
"ipad": ["jotting.png", "applepencil.png", "icloud.png", "darkmode.png", "share.png"]
},
"mac": {
"mac": ["jotting.png", "icloud.png", "darkmode.png", "share.png"]
}
}
},
"hi": {
"name": "Jottre",
"subtitle": "सहज हस्तलेखन",
"description": "Jottre हस्तलिखित नोट्स और स्केच के लिए एक सरल कैनवस है। iPad पर Apple Pencil से या iPhone पर उंगली से बेझिझक लिखें। आपके नोट्स iCloud के ज़रिए iPhone, iPad और Mac पर अपने आप सिंक होते हैं। साझा करना हो तो PDF, JPG या PNG के रूप में निर्यात करें।",
"keywords": "नोट्स,लेखन,स्केच,कैनवस,सरल,व्हाइटबोर्ड,त्वरित नोट्स,डायग्राम,कागज़,चित्र,हस्तलेखन,ड्राइंग,मिनिमल",
"promotional_text": "सरल और न्यूनतम लेखन।",
"release_notes": "Jottre 2.0 में वही बातें बरकरार हैं जो अच्छी थीं, साथ में सोच-समझकर किए गए सुधार।",
"marketing_url": "https://github.com/antonlorani/jottre",
"privacy_url": "https://github.com/antonlorani/jottre/blob/master/PRIVACY_POLICY.md",
"support_url": "https://github.com/antonlorani/jottre/issues",
"screenshots": {
"ios": {
"iphone": ["jotting.png", "icloud.png", "darkmode.png", "share.png"],
"ipad": ["jotting.png", "applepencil.png", "icloud.png", "darkmode.png", "share.png"]
},
"mac": {
"mac": ["jotting.png", "icloud.png", "darkmode.png", "share.png"]
}
}
},
"ko": {
"name": "Jottre",
"subtitle": "미니멀한 손글씨",
"description": "Jottre는 손글씨 메모와 스케치를 위한 간결한 캔버스입니다. iPad에서는 Apple Pencil로, iPhone에서는 손끝으로 자유롭게 적어 보세요. 작성한 메모는 iCloud를 통해 iPhone, iPad, Mac에서 자동으로 동기화됩니다. 공유할 때는 PDF, JPG, PNG로 손쉽게 내보낼 수 있습니다.",
"keywords": "메모,손글씨,스케치,노트,미니멀,화이트보드,빠른메모,다이어그램,캔버스,그림,종이,글쓰기,필기,아이패드,애플펜슬,낙서,다이어리,스케치북",
"promotional_text": "간결하고 미니멀한 메모.",
"release_notes": "Jottre 2.0은 좋았던 점은 그대로 두고, 곳곳을 세심하게 다듬었습니다.",
"marketing_url": "https://github.com/antonlorani/jottre",
"privacy_url": "https://github.com/antonlorani/jottre/blob/master/PRIVACY_POLICY.md",
"support_url": "https://github.com/antonlorani/jottre/issues",
"screenshots": {
"ios": {
"iphone": ["jotting.png", "icloud.png", "darkmode.png", "share.png"],
"ipad": ["jotting.png", "applepencil.png", "icloud.png", "darkmode.png", "share.png"]
},
"mac": {
"mac": ["jotting.png", "icloud.png", "darkmode.png", "share.png"]
}
}
}
}
================================================
FILE: fastlane/appstoreconnect/screenshots/.gitignore
================================================
*
!.gitignore
================================================
FILE: fastlane/export_screenshots.rb
================================================
# frozen_string_literal: true
# Jottre: Minimalistic jotting for iPhone, iPad and Mac.
# Copyright (C) 2021-2026 Anton Lorani
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
require 'fileutils'
# Export artboards from a Sketch file and group by locale & platform.
#
# Artboards must be named: __
# Example: iphone_jotting_de, iphone_quick_note_de
# Output structure: ///.png
module ExportScreenshots
LOCALES = %w[de en es fr hi ko].freeze
# Exports artboards from the given Sketch file into output_dir, grouped by
# locale and platform.
def self.export(sketch_file:, output_dir:)
ui = FastlaneCore::UI
ui.user_error!("Sketch file not found: #{sketch_file}") unless File.exist?(sketch_file)
FileUtils.mkdir_p(output_dir)
ui.message("Exporting artboards from #{sketch_file}...")
unless system('sketchtool', 'export', 'artboards', sketch_file, "--output=#{output_dir}")
ui.user_error!('sketchtool export failed (is sketchtool in your PATH?)')
end
files = Dir.glob(File.join(output_dir, '*.png'))
ui.user_error!("No PNG files found in #{output_dir}") if files.empty?
ui.message("Grouping #{files.length} file(s)...")
files.each { |file| group_file(file, output_dir: output_dir, ui: ui) }
ui.message('Done.')
end
def self.group_file(file, output_dir:, ui:)
source_name = File.basename(file)
platform, *rest = File.basename(file, '.png').split('_')
locale = rest.pop
name = rest.join('_')
if platform.nil? || locale.nil? || name.empty?
ui.message(" Skipping #{source_name} (doesn't match __)")
return
end
unless LOCALES.include?(locale)
ui.message(" Skipping #{source_name} (unknown locale: #{locale})")
return
end
destination = File.join(output_dir, locale, platform, "#{name}.png")
FileUtils.mkdir_p(File.dirname(destination))
FileUtils.mv(file, destination)
ui.message(" #{source_name} -> #{destination}")
end
end
================================================
FILE: fastlane/versioning.rb
================================================
# frozen_string_literal: true
# Jottre: Minimalistic jotting for iPhone, iPad and Mac.
# Copyright (C) 2021-2026 Anton Lorani
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
module Versioning
SEMVER_PATTERN = /\A\d+\.\d+\.\d+\z/
# Returns the latest semver tag or nil if none exists.
def self.latest_tag
tags = `git tag --sort=-version:refname 2>/dev/null`.strip
tags.each_line do |line|
tag = line.strip
return tag if tag.match?(SEMVER_PATTERN)
end
nil
end
# Returns true if HEAD is already tagged.
def self.head_tagged?(tag)
`git describe --exact-match --tags HEAD 2>/dev/null || true`.strip == tag
end
# Returns the next version bump strategy suitable since the last tag, or
# nil if no version marker is present.
def self.bump_strategy(since_tag:)
range = since_tag ? "#{since_tag}..HEAD" : 'HEAD'
subjects = `git log #{range} --pretty=format:'%s'`.strip
strategy = nil
subjects.each_line do |line|
line = line.strip
next if line.empty?
if line.match?(/^major:/i)
strategy = 'major'
break
elsif line.match?(/^minor:/i) && strategy != 'major'
strategy = 'minor'
elsif line.match?(/^patch:/i) && strategy.nil?
strategy = 'patch'
end
end
strategy
end
# Performs a semver bump on a given semantic version.
def self.bump_version(current:, bump_strategy:)
parts = current.split('.').map(&:to_i)
parts << 0 while parts.length < 3
major, minor, patch = parts
case bump_strategy
when 'major' then "#{major + 1}.0.0"
when 'minor' then "#{major}.#{minor + 1}.0"
else "#{major}.#{minor}.#{patch + 1}"
end
end
end
================================================
FILE: hooks/install_hooks.sh
================================================
#!/bin/bash
set -e
hooks=("pre-commit")
top_level_dir=$(git rev-parse --show-toplevel)
source_hooks_dir="$top_level_dir/hooks"
destination_hooks_dir="$top_level_dir/.git/hooks"
if [ ! -d "$destination_hooks_dir" ]; then
echo "Creating missing '$destination_hooks_dir' directory."
mkdir -p "$destination_hooks_dir"
fi
for hook in "${hooks[@]}"; do
ln -sf "$source_hooks_dir/$hook" "$destination_hooks_dir/$hook"
echo "Creating symbolic link for '$hook'."
done
================================================
FILE: hooks/pre-commit
================================================
#!/bin/bash
STAGED_RUBY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.rb$')
if [ -n "$STAGED_RUBY_FILES" ]; then
bundle exec rubocop --autocorrect-all $STAGED_RUBY_FILES
MODIFIED=$(git diff --name-only $STAGED_RUBY_FILES)
if [ -n "$MODIFIED" ]; then
echo -e "\033[0;31mFormatting applied. Restage affected files.\033[0m"
exit 1
fi
fi
STAGED_SWIFT_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.swift$')
if [ -n "$STAGED_SWIFT_FILES" ]; then
NEEDS_FORMATTING=0
for FILE in $STAGED_SWIFT_FILES; do
BEFORE=$(git show ":$FILE")
AFTER=$(swift format - <<< "$BEFORE")
if ! diff -q <(echo "$BEFORE") <(echo "$AFTER") > /dev/null; then
NEEDS_FORMATTING=1
fi
done
if [ $NEEDS_FORMATTING -eq 1 ]; then
echo "Running swift format..."
swift format $STAGED_SWIFT_FILES --in-place --parallel
echo -e "\033[0;31mFormatting applied. Restage affected files.\033[0m"
exit 1
fi
fi
exit 0
================================================
FILE: project.yml
================================================
name: Jottre
options:
bundleIdPrefix: com.antonlorani
deploymentTarget:
iOS: "15.0"
knownRegions:
- en
- de
- af
- ar
- es
- fr
- hi
- id
- it
- ja
- ko
- ms
- nl
- pl
- pt-BR
- pt-PT
- ru
- sv
- th
- tr
- uk
- vi
settings:
base:
DEVELOPMENT_TEAM: Y78RPE9KK3
targets:
Jottre:
type: application
platform: iOS
deploymentTarget: "15.0"
sources:
- Sources
- path: Resources/Assets.xcassets
- path: Resources/Localizable.xcstrings
- path: Resources/PrivacyInfo.xcprivacy
info:
path: Resources/Info.plist
properties:
LSApplicationCategoryType: public.app-category.productivity
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleDocumentTypes:
- CFBundleTypeName: Jottre Jot
CFBundleTypeRole: Editor
LSHandlerRank: Owner
LSItemContentTypes:
- com.antonlorani.jottre.jot
LSApplicationQueriesSchemes:
- shareddocuments
UTExportedTypeDeclarations:
- UTTypeIdentifier: com.antonlorani.jottre.jot
UTTypeDescription: Jottre Jot
UTTypeConformsTo:
- public.data
UTTypeTagSpecification:
public.filename-extension:
- jot
UIFileSharingEnabled: true
LSSupportsOpeningDocumentsInPlace: true
NSQuitAlwaysKeepsWindows: true
NSUserActivityTypes:
- com.antonlorani.jottre.openJot
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: true
UISceneConfigurations:
UIWindowSceneSessionRoleApplication:
- UISceneConfigurationName: "Default Configuration"
UISceneDelegateClassName: "$(PRODUCT_MODULE_NAME).SceneDelegate"
NSUbiquitousContainers:
iCloud.com.antonlorani.jottre:
NSUbiquitousContainerIsDocumentScopePublic: true
NSUbiquitousContainerSupportedFolderLevels: Any
NSUbiquitousContainerName: Jottre
entitlements:
path: Jottre.entitlements
properties:
com.apple.security.files.user-selected.read-write: true
com.apple.developer.icloud-container-identifiers:
- iCloud.com.antonlorani.jottre
com.apple.developer.icloud-services:
- CloudDocuments
com.apple.developer.ubiquity-container-identifiers:
- iCloud.com.antonlorani.jottre
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.antonlorani.jottre
PRODUCT_NAME: Jottre
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
SUPPORTS_MACCATALYST: YES
MACCATALYST_UI_IDIOM: mac
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER: NO
BUNDLE_IDENTIFIER_MACCATALYST: com.antonlorani.jottre
SWIFT_EMIT_LOC_STRINGS: YES
ENABLE_HARDENED_RUNTIME: YES
ENABLE_APP_SANDBOX: YES
ENABLE_USER_SCRIPT_SANDBOXING: YES
VALIDATE_PRODUCT: YES
EXCLUDED_SOURCE_FILE_NAMES[sdk=iphoneos*]: AppKitPlugin.bundle
dependencies:
- target: AppKitPlugin
embed: true
codeSign: true
packages: []
JottreTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "15.0"
sources:
- path: Tests
excludes:
- Resources/**
- path: Tests/Resources
buildPhase: resources
dependencies:
- target: Jottre
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.antonlorani.jottre.JottreTests
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
GENERATE_INFOPLIST_FILE: YES
AppKitPlugin:
type: bundle
platform: macOS
deploymentTarget: "12.0"
sources:
- AppKitPlugin
info:
path: AppKitPlugin/Info.plist
properties:
CFBundleName: AppKitPlugin
CFBundleIdentifier: com.antonlorani.jottre.AppKitPlugin
CFBundleVersion: 1
CFBundleShortVersionString: 1.0
CFBundlePackageType: BNDL
CFBundleExecutable: AppKitPlugin
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.antonlorani.jottre.AppKitPlugin
SWIFT_VERSION: "6.0"
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "-"
MACH_O_TYPE: mh_bundle
ENABLE_HARDENED_RUNTIME: YES
schemes:
Jottre:
build:
targets:
Jottre: all
AppKitPlugin: all
run:
config: Debug
executable: Jottre
test:
config: Debug
targets:
- name: JottreTests
archive:
config: Release
profile:
config: Release
analyze:
config: Debug
JottreTests:
build:
targets:
JottreTests: all
Jottre: all
test:
config: Debug
targets:
- name: JottreTests
analyze:
config: Debug
AppKitPlugin:
build:
targets:
AppKitPlugin: all
run:
config: Debug
archive:
config: Release
profile:
config: Release
analyze:
config: Debug