mirror of
https://github.com/otaviocc/Triton.git
synced 2026-01-30 04:04:27 +00:00
Compare commits
48 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f79e55c5 | ||
|
|
46fd069259 | ||
|
|
d4aecbe8ed | ||
|
|
3ed032e8df | ||
|
|
afdd145a63 | ||
|
|
ee174ab836 | ||
|
|
d3b596c26f | ||
|
|
1a105917b2 | ||
|
|
2de32c7160 | ||
|
|
b9e3362d1d | ||
|
|
eaef589399 | ||
|
|
4d95a7d40f | ||
|
|
c9b6d624a2 | ||
|
|
5e0ad765e1 | ||
|
|
e1098916ab | ||
|
|
a97a1981e0 | ||
|
|
833f9e920d | ||
|
|
07ea603267 | ||
|
|
eedd369c37 | ||
|
|
c1aaf3af68 | ||
|
|
3a86d153ea | ||
|
|
8572716a73 | ||
|
|
5a2ebf5017 | ||
|
|
c35418211d | ||
|
|
a52511547c | ||
|
|
d4aa1f066f | ||
|
|
91435ff1ce | ||
|
|
793903352c | ||
|
|
c837a2b788 | ||
|
|
ef2ab718d7 | ||
|
|
22956e834c | ||
|
|
1392ad0cc0 | ||
|
|
d840caa026 | ||
|
|
3365b19b8f | ||
|
|
958d410ddc | ||
|
|
e6d378dfef | ||
|
|
17398e7dc0 | ||
|
|
2aad39cf37 | ||
|
|
d54913af73 | ||
|
|
d15f7a1be3 | ||
|
|
f5a4bf476d | ||
|
|
1791cab97f | ||
|
|
561ec4e028 | ||
|
|
853a1e1e8b | ||
|
|
9115e25017 | ||
|
|
ba7c8fcef8 | ||
|
|
05e3920841 | ||
|
|
27f4998cdf |
113 changed files with 3129 additions and 520 deletions
45
.github/workflows/build.yml
vendored
Normal file
45
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build OMG
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode version
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '26.1.1'
|
||||
|
||||
- name: Show Xcode version
|
||||
run: xcodebuild -version
|
||||
|
||||
- name: Cache Swift Package Manager
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.build
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-spm-
|
||||
|
||||
- name: Resolve Swift Package dependencies
|
||||
run: xcodebuild -resolvePackageDependencies -project OMG.xcodeproj -scheme OMG
|
||||
|
||||
- name: Build OMG
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-project OMG.xcodeproj \
|
||||
-scheme OMG \
|
||||
-configuration Debug \
|
||||
-destination 'platform=macOS' \
|
||||
| xcpretty || exit 1
|
||||
continue-on-error: false
|
||||
126
.github/workflows/draft-release.yml
vendored
Normal file
126
.github/workflows/draft-release.yml
vendored
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
name: Create Draft Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
|
||||
jobs:
|
||||
create-draft-release:
|
||||
name: Create Draft Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get previous tag
|
||||
id: previous_tag
|
||||
run: |
|
||||
# Get all tags sorted by version
|
||||
PREVIOUS_TAG=$(git tag -l '[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | sed -n '2p')
|
||||
echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Previous tag: $PREVIOUS_TAG"
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ github.ref_name }}"
|
||||
PREVIOUS_TAG="${{ steps.previous_tag.outputs.previous_tag }}"
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "No previous tag found. This is the first release."
|
||||
# Get all merged PRs
|
||||
PRS=$(gh pr list --state merged --json number,title,mergedAt --jq '.[] | "\(.number)|\(.title)"')
|
||||
else
|
||||
# Get the date of the previous tag
|
||||
PREVIOUS_DATE=$(git log -1 --format=%aI $PREVIOUS_TAG)
|
||||
echo "Previous tag date: $PREVIOUS_DATE"
|
||||
|
||||
# Get all PRs merged since the previous tag
|
||||
PRS=$(gh pr list --state merged --search "merged:>=$PREVIOUS_DATE" --json number,title --jq '.[] | "\(.number)|\(.title)"')
|
||||
fi
|
||||
|
||||
echo "Found PRs:"
|
||||
echo "$PRS"
|
||||
echo "---"
|
||||
|
||||
# Separate fixes from other PRs
|
||||
FIXES=""
|
||||
OTHERS=""
|
||||
|
||||
while IFS='|' read -r PR_NUM PR_TITLE; do
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
PR_LINE="- https://github.com/${{ github.repository }}/pull/$PR_NUM - $PR_TITLE"
|
||||
|
||||
# Check if it's a fix (case-insensitive)
|
||||
if echo "$PR_TITLE" | grep -iE "^(fix|bugfix|hotfix)" > /dev/null; then
|
||||
FIXES="$FIXES$PR_LINE"$'\n'
|
||||
else
|
||||
OTHERS="$OTHERS$PR_LINE"$'\n'
|
||||
fi
|
||||
fi
|
||||
done <<< "$PRS"
|
||||
|
||||
# Create release notes
|
||||
{
|
||||
if [ -n "$FIXES" ]; then
|
||||
echo "This release fixes:"
|
||||
echo ""
|
||||
echo "$FIXES"
|
||||
fi
|
||||
|
||||
if [ -n "$OTHERS" ]; then
|
||||
if [ -n "$FIXES" ]; then
|
||||
echo "Other Pull Requests:"
|
||||
else
|
||||
echo "This release includes:"
|
||||
fi
|
||||
echo ""
|
||||
echo "$OTHERS"
|
||||
fi
|
||||
|
||||
if [ -z "$FIXES" ] && [ -z "$OTHERS" ]; then
|
||||
echo "No pull requests were merged in this release."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "## Installation"
|
||||
echo ""
|
||||
echo "**Download directly from GitHub Releases**"
|
||||
echo ""
|
||||
echo "Download the \`.zip\` file from this release, extract it, and drag OMG.app to your Applications folder."
|
||||
echo ""
|
||||
echo "**Install via [Homebrew](https://brew.sh)**"
|
||||
echo ""
|
||||
echo "\`\`\`bash"
|
||||
echo "brew tap otaviocc/apps"
|
||||
echo "brew install --cask triton"
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
echo "**Upgrade via Homebrew**"
|
||||
echo ""
|
||||
echo "If you installed Triton using Homebrew, upgrade to the latest version with:"
|
||||
echo ""
|
||||
echo "\`\`\`bash"
|
||||
echo "brew upgrade --cask triton"
|
||||
echo "\`\`\`"
|
||||
} > release_notes.md
|
||||
|
||||
cat release_notes.md
|
||||
|
||||
- name: Create Draft Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
name: Release ${{ github.ref_name }}
|
||||
body_path: release_notes.md
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
20
.github/workflows/swiftformat.yml
vendored
Normal file
20
.github/workflows/swiftformat.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: SwiftFormat Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
swiftformat:
|
||||
name: Code Formatting Check
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install swiftformat
|
||||
run: brew install swiftformat
|
||||
|
||||
- name: Check code formatting
|
||||
run: swiftformat --lint .
|
||||
20
.github/workflows/swiftlint.yml
vendored
Normal file
20
.github/workflows/swiftlint.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: SwiftLint Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
swiftlint:
|
||||
name: SwiftLint
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install SwiftLint
|
||||
run: brew install swiftlint
|
||||
|
||||
- name: Run SwiftLint
|
||||
run: swiftlint lint --reporter github-actions-logging
|
||||
45
.github/workflows/unit-tests.yml
vendored
Normal file
45
.github/workflows/unit-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Unit Tests
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode version
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '26.1.1'
|
||||
|
||||
- name: Show Xcode version
|
||||
run: xcodebuild -version
|
||||
|
||||
- name: Cache Swift Package Manager
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.build
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-spm-
|
||||
|
||||
- name: Resolve Swift Package dependencies
|
||||
run: xcodebuild -resolvePackageDependencies -project OMG.xcodeproj -scheme OMG
|
||||
|
||||
- name: Run OMG Tests
|
||||
run: |
|
||||
xcodebuild test \
|
||||
-project OMG.xcodeproj \
|
||||
-scheme OMG \
|
||||
-configuration Debug \
|
||||
-destination 'platform=macOS' \
|
||||
| xcpretty || exit 1
|
||||
continue-on-error: false
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
--ifdef indent
|
||||
|
||||
# MARK: - SwiftUI and Modern Swift Features
|
||||
--swift-version 6.0
|
||||
--swift-version 6.2
|
||||
--sort-swiftui-properties first-appearance-sort
|
||||
--some-any true
|
||||
--short-optionals always
|
||||
|
|
|
|||
64
.swiftlint.yml
Normal file
64
.swiftlint.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Minimal SwiftLint configuration
|
||||
# SwiftFormat handles all formatting, so SwiftLint focuses on code quality and best practices
|
||||
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- opening_brace
|
||||
- function_parameter_count
|
||||
- nesting
|
||||
- large_tuple
|
||||
- type_name
|
||||
- force_unwrapping
|
||||
- blanket_disable_command
|
||||
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- contains_over_first_not_nil
|
||||
- convenience_type
|
||||
- discouraged_assert
|
||||
- discouraged_object_literal
|
||||
- empty_count
|
||||
- empty_string
|
||||
- empty_xctest_method
|
||||
- explicit_init
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- force_cast
|
||||
- force_try
|
||||
- implicit_return
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- legacy_random
|
||||
- lower_acl_than_parent
|
||||
- multiline_function_chains
|
||||
- multiline_parameters
|
||||
- multiline_parameters_brackets
|
||||
- no_fallthrough_only
|
||||
- operator_usage_whitespace
|
||||
- overridden_super_call
|
||||
- prohibited_super_call
|
||||
- redundant_nil_coalescing
|
||||
- single_test_class
|
||||
- sorted_first_last
|
||||
- static_operator
|
||||
- switch_case_alignment
|
||||
- trailing_closure
|
||||
- unavailable_function
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- unused_control_flow_label
|
||||
- vertical_whitespace_closing_braces
|
||||
- xct_specific_matcher
|
||||
- xctfail_message
|
||||
- yoda_condition
|
||||
|
||||
multiline_parameters:
|
||||
allows_single_line: false
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- "**/.build"
|
||||
- DerivedData
|
||||
- Pods
|
||||
- Carthage
|
||||
- .git
|
||||
- node_modules
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
# ADR-017: Direct Distribution Outside App Store
|
||||
|
||||
**Status:** Accepted
|
||||
|
||||
**Date:** 2025-01-22
|
||||
|
||||
**Context:**
|
||||
|
||||
When deciding how to distribute the macOS application, I evaluated two primary distribution channels:
|
||||
|
||||
1. **Mac App Store:** Apple's official marketplace with built-in distribution and discovery
|
||||
2. **Direct distribution:** Downloadable DMG/PKG outside the App Store (sideloading)
|
||||
|
||||
Key considerations influencing this decision:
|
||||
|
||||
- **Development velocity:** Ability to iterate and release updates quickly
|
||||
- **Review process overhead:** Time and friction introduced by App Store review cycles
|
||||
- **Cost implications:** Additional expenses beyond existing Apple Developer Program membership
|
||||
- **Security and trust:** Maintaining user confidence through proper code signing and notarization
|
||||
- **Control over distribution:** Flexibility in release timing and update deployment
|
||||
|
||||
The application requires an omg.lol account for authentication and core functionality. Apple's App Store review process requires providing reviewers with a functional test account, which would necessitate purchasing an additional omg.lol address specifically for review purposes.
|
||||
|
||||
**Decision:**
|
||||
|
||||
I chose to distribute the application directly outside the Mac App Store through downloadable DMG or PKG installers. Users download and install the application manually (sideloading), bypassing the App Store entirely.
|
||||
|
||||
**Distribution Approach:**
|
||||
|
||||
1. **Direct downloads:** Hosted files available from GitHub releases
|
||||
2. **Code signing:** Application binary signed with Apple Developer ID certificate
|
||||
3. **Notarization:** Binary submitted to Apple's notarization service for malware scanning
|
||||
4. **Gatekeeper compliance:** Notarized app passes macOS Gatekeeper checks on first launch
|
||||
5. **Manual updates:** Users download and install updates manually
|
||||
|
||||
**Security Measures (Still Applied):**
|
||||
|
||||
Despite not being distributed through the App Store, the application maintains the same security practices:
|
||||
|
||||
- **Code signing required:** Binary must be signed with valid Developer ID
|
||||
- **Notarization required:** Apple's automated security scan must pass before distribution
|
||||
- **Gatekeeper verification:** macOS verifies signature and notarization on first launch
|
||||
- **Static analysis:** Binary undergoes Apple's automated malware and security checks
|
||||
- **Developer accountability:** Apple Developer ID ties the application to verified developer account
|
||||
|
||||
The only difference from App Store distribution is the absence of manual human review. All automated security checks, signing requirements, and notarization processes remain identical.
|
||||
|
||||
**Consequences:**
|
||||
|
||||
### Positive
|
||||
|
||||
- **Faster iteration cycles:** No waiting for App Store review approval (typically 24-48 hours per submission)
|
||||
- **Immediate releases:** Updates can be deployed as soon as they're ready
|
||||
- **No review friction:** Avoid potential rejections requiring code changes and resubmission
|
||||
- **Cost savings:** No additional omg.lol subscription required for App Store review account ($20/year saved)
|
||||
- **Developer Program only:** Single $99/year Apple Developer membership covers all requirements
|
||||
- **Release control:** Full control over timing, rollback, and phased rollouts
|
||||
- **No guideline restrictions:** Freedom from App Store Review Guidelines constraints (beyond security requirements)
|
||||
- **Testing flexibility:** No need to maintain separate review-specific test accounts
|
||||
|
||||
### Negative
|
||||
|
||||
- **No App Store discovery:** Users cannot find the app through Mac App Store search
|
||||
- **Manual update flow:** Users must manually check for and install updates (unless in-app updater implemented)
|
||||
- **Trust barrier:** Some users hesitate to install applications outside the App Store
|
||||
- **Distribution responsibility:** Must host files on reliable infrastructure (GitHub releases)
|
||||
- **No App Store features:** Cannot leverage TestFlight, automatic updates, or App Store metadata/screenshots for marketing
|
||||
|
||||
### Neutral
|
||||
|
||||
- **Notarization turnaround:** Apple notarization still required but typically completes within minutes (faster than full review)
|
||||
- **Gatekeeper warnings:** First launch shows standard "downloaded from internet" warning (expected for all non-App Store apps)
|
||||
- **Marketing channels:** Must rely on direct marketing, social media, and community rather than App Store presence
|
||||
|
||||
**Why This Doesn't Compromise Quality:**
|
||||
|
||||
Direct distribution does not mean lower security or quality standards. The application still undergoes:
|
||||
|
||||
1. **Developer ID signing:** Cryptographic signature proving developer identity
|
||||
2. **Notarization:** Apple's automated security analysis scanning for malware and policy violations
|
||||
3. **Gatekeeper checks:** macOS verifies the app's signature and notarization before first launch
|
||||
4. **Same binary standards:** Hardened runtime, code signing requirements identical to App Store builds
|
||||
|
||||
The primary difference is the absence of manual human review, not the absence of security checks. App Store review primarily enforces guideline compliance (UI/UX standards, business model rules, content policies) rather than discovering security issues that automated tools miss.
|
||||
|
||||
**Cost-Benefit Analysis:**
|
||||
|
||||
- **Current cost:** $99/year Apple Developer Program (required for notarization and signing)
|
||||
- **App Store additional cost:** $20/year omg.lol test account (20% overhead)
|
||||
- **App Store time cost:** 24-48 hours per release (blocks urgent fixes)
|
||||
- **Development velocity value:** Immediate releases enable faster user feedback and bug fixes
|
||||
|
||||
For a single-developer project with an existing account requirement, avoiding the App Store review process provides better ROI through faster iteration and lower operational overhead.
|
||||
|
||||
**Related Decisions:**
|
||||
|
||||
- Future consideration: Could revisit App Store distribution if user acquisition through App Store becomes strategically valuable
|
||||
- Potential automation: Could implement Sparkle framework or similar for automated update checks
|
||||
|
||||
**Notes:**
|
||||
|
||||
This decision prioritizes development velocity and cost efficiency while maintaining identical security standards through code signing and notarization. Direct distribution is a common and legitimate approach for many professional macOS applications (Homebrew, VS Code, Docker Desktop, etc.).
|
||||
|
|
@ -76,6 +76,11 @@ Standardized context menu structure for content items using native ShareLink for
|
|||
### [ADR-016: SwiftUI Previews with Mother Objects](ADR-016-swiftui-previews-with-mother-objects.md)
|
||||
Use of Mother Object pattern for creating reusable test fixtures that support SwiftUI Previews across 90% of views, enabling rapid UI development with realistic data.
|
||||
|
||||
## Distribution
|
||||
|
||||
### [ADR-017: Direct Distribution Outside App Store](ADR-017-direct-distribution-outside-app-store.md)
|
||||
Decision to distribute the application directly through downloadable installers outside the Mac App Store, prioritizing development velocity and cost efficiency while maintaining security through code signing and notarization.
|
||||
|
||||
## Contributing
|
||||
|
||||
When making significant architectural decisions:
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@
|
|||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EAC944E3295E56EA004EE34F /* Build configuration list for PBXNativeTarget "OMG" */;
|
||||
buildPhases = (
|
||||
EA67B2142F17DE3400A145FB /* SwiftFormat */,
|
||||
EA67B2152F17DF3300A145FB /* SwiftLint */,
|
||||
EAC944D0295E56E9004EE34F /* Sources */,
|
||||
EAC944D1295E56E9004EE34F /* Frameworks */,
|
||||
EAC944D2295E56E9004EE34F /* Resources */,
|
||||
|
|
@ -239,6 +241,47 @@
|
|||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
EA67B2142F17DE3400A145FB /* SwiftFormat */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = SwiftFormat;
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "#!/bin/zsh\n\n# Add Homebrew paths to PATH\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\n# Exit successfully if swiftformat is not installed\nif ! command -v swiftformat &> /dev/null; then\n echo \"warning: SwiftFormat not installed, skipping formatting\"\n exit 0\nfi\n\n# Only run on actual builds, not indexing\nif [[ \"${ACTION}\" == \"indexbuild\" ]]; then\n exit 0\nfi\n\n# Change to source root to ensure .swiftformat is accessible\ncd \"${SRCROOT}\" || exit 0\n\n# Run SwiftFormat on the project directory\nswiftformat . --lint --lenient 2>&1 | while IFS= read -r line; do\n echo \"${line}\"\ndone\n\necho \"SwiftFormat completed successfully\"\nexit 0\n";
|
||||
};
|
||||
EA67B2152F17DF3300A145FB /* SwiftLint */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = SwiftLint;
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "#!/bin/zsh\n\n# Add Homebrew paths to PATH\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\n# Exit successfully if swiftlint is not installed\nif ! command -v swiftlint &> /dev/null; then\n echo \"warning: SwiftLint not installed, skipping linting\"\n exit 0\nfi\n\n# Only run on actual builds, not indexing\nif [[ \"${ACTION}\" == \"indexbuild\" ]]; then\n exit 0\nfi\n\n# Change to source root to ensure .swiftlint.yml is accessible\ncd \"${SRCROOT}\" || exit 0\n\n# Run SwiftLint and report violations as Xcode warnings/errors\nswiftlint lint --quiet --reporter xcode\n\nexit 0\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
EAC944D0295E56E9004EE34F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
|
|
@ -390,6 +433,7 @@
|
|||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "OMG/Supporting Files/Info.plist";
|
||||
|
|
@ -408,7 +452,7 @@
|
|||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.OMG;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
|
|
@ -439,6 +483,7 @@
|
|||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "OMG/Supporting Files/Info.plist";
|
||||
|
|
@ -457,7 +502,7 @@
|
|||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.OMG;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,176 @@
|
|||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AuthNetworkServiceTests"
|
||||
BuildableName = "AuthNetworkServiceTests"
|
||||
BlueprintName = "AuthNetworkServiceTests"
|
||||
ReferencedContainer = "container:Packages/Auth">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AuthPersistenceServiceTests"
|
||||
BuildableName = "AuthPersistenceServiceTests"
|
||||
BlueprintName = "AuthPersistenceServiceTests"
|
||||
ReferencedContainer = "container:Packages/Auth">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AuthRepositoryTests"
|
||||
BuildableName = "AuthRepositoryTests"
|
||||
BlueprintName = "AuthRepositoryTests"
|
||||
ReferencedContainer = "container:Packages/Auth">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AuthSessionServiceTests"
|
||||
BuildableName = "AuthSessionServiceTests"
|
||||
BlueprintName = "AuthSessionServiceTests"
|
||||
ReferencedContainer = "container:Packages/AuthSession">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FoundationExtensionsTests"
|
||||
BuildableName = "FoundationExtensionsTests"
|
||||
BlueprintName = "FoundationExtensionsTests"
|
||||
ReferencedContainer = "container:Packages/FoundationExtensions">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "NowTests"
|
||||
BuildableName = "NowTests"
|
||||
BlueprintName = "NowTests"
|
||||
ReferencedContainer = "container:Packages/Now">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "OMGAPITests"
|
||||
BuildableName = "OMGAPITests"
|
||||
BlueprintName = "OMGAPITests"
|
||||
ReferencedContainer = "container:Packages/OMGAPI">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PastebinTests"
|
||||
BuildableName = "PastebinTests"
|
||||
BlueprintName = "PastebinTests"
|
||||
ReferencedContainer = "container:Packages/Pastebin">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PicsTests"
|
||||
BuildableName = "PicsTests"
|
||||
BlueprintName = "PicsTests"
|
||||
ReferencedContainer = "container:Packages/Pics">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PURLsTests"
|
||||
BuildableName = "PURLsTests"
|
||||
BlueprintName = "PURLsTests"
|
||||
ReferencedContainer = "container:Packages/PURLs">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "SessionServiceTests"
|
||||
BuildableName = "SessionServiceTests"
|
||||
BlueprintName = "SessionServiceTests"
|
||||
ReferencedContainer = "container:Packages/SessionService">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "StatusNetworkServiceTests"
|
||||
BuildableName = "StatusNetworkServiceTests"
|
||||
BlueprintName = "StatusNetworkServiceTests"
|
||||
ReferencedContainer = "container:Packages/Status">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "StatusPersistenceServiceTests"
|
||||
BuildableName = "StatusPersistenceServiceTests"
|
||||
BlueprintName = "StatusPersistenceServiceTests"
|
||||
ReferencedContainer = "container:Packages/Status">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "StatusRepositoryTests"
|
||||
BuildableName = "StatusRepositoryTests"
|
||||
BlueprintName = "StatusRepositoryTests"
|
||||
ReferencedContainer = "container:Packages/Status">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "ArchiverTests"
|
||||
BuildableName = "ArchiverTests"
|
||||
BlueprintName = "ArchiverTests"
|
||||
ReferencedContainer = "container:Packages/Utilities">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "WeblogTests"
|
||||
BuildableName = "WeblogTests"
|
||||
BlueprintName = "WeblogTests"
|
||||
ReferencedContainer = "container:Packages/Weblog">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "WebpageTests"
|
||||
BuildableName = "WebpageTests"
|
||||
BlueprintName = "WebpageTests"
|
||||
ReferencedContainer = "container:Packages/Webpage">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
authSessionServiceFactory: any AuthSessionServiceFactoryProtocol,
|
||||
sessionServiceFactory: any SessionServiceFactoryProtocol,
|
||||
|
|
@ -233,11 +234,9 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
|
|||
allocation: .static
|
||||
) { container in
|
||||
let authSessionService = container.resolve() as any AuthSessionServiceProtocol
|
||||
return networkClient.makeOMGAPIClient(
|
||||
authTokenProvider: {
|
||||
return networkClient.makeOMGAPIClient {
|
||||
await authSessionService.accessToken
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
container.register(
|
||||
|
|
@ -375,4 +374,5 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ struct TritonScene: Scene {
|
|||
#endif
|
||||
.environment(makeAccountUpdateService())
|
||||
.handlesExternalEvents(
|
||||
preferring: Set(arrayLiteral: "viewer"),
|
||||
allowing: Set(arrayLiteral: "*")
|
||||
preferring: ["viewer"],
|
||||
allowing: ["*"]
|
||||
)
|
||||
.onAppear {
|
||||
environment
|
||||
|
|
|
|||
|
|
@ -76,11 +76,19 @@ private extension AccountResponse {
|
|||
|
||||
/// Initializes the `AccountResponse` model from the network response
|
||||
/// model, so that the client doesn't depend on network models.
|
||||
///
|
||||
/// If the name from the API response is `nil` or empty, it defaults to "Anonymous"
|
||||
/// to ensure all layers of the application work with a non-optional name value.
|
||||
///
|
||||
/// - Parameter response: The network model to be mapped.
|
||||
init(
|
||||
response: AccountInformationResponse.Response
|
||||
) {
|
||||
name = response.name
|
||||
if let responseName = response.name, !responseName.isEmpty {
|
||||
name = responseName
|
||||
} else {
|
||||
name = "Anonymous"
|
||||
}
|
||||
email = response.email
|
||||
unixEpochTime = response.created.unixEpochTime
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,13 +70,11 @@ actor AccountUpdateRepository: AccountUpdateRepositoryProtocol {
|
|||
await fetchAccountInformation()
|
||||
}
|
||||
|
||||
for await isLoggedIn in authSessionService.observeLoginState() {
|
||||
if isLoggedIn {
|
||||
for await isLoggedIn in authSessionService.observeLoginState() where isLoggedIn {
|
||||
await fetchAccountInformation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAccountInformation() async {
|
||||
guard await authSessionService.isLoggedIn else { return }
|
||||
|
|
|
|||
|
|
@ -25,13 +25,11 @@ final class AuthAppViewModel {
|
|||
let currentState = await authSessionService.isLoggedIn
|
||||
isLoggedIn = currentState
|
||||
|
||||
for await loginState in authSessionService.observeLoginState() {
|
||||
if isLoggedIn != loginState {
|
||||
for await loginState in authSessionService.observeLoginState() where isLoggedIn != loginState {
|
||||
isLoggedIn = loginState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
observationTask?.cancel()
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ final class AuthSessionServiceTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testObserveLoginStateYieldsCurrentState() async {
|
||||
func testObserveLoginStateYieldsCurrentState() async throws {
|
||||
// Given
|
||||
let initialState = await service.isLoggedIn
|
||||
|
||||
|
|
@ -97,9 +97,8 @@ final class AuthSessionServiceTests: XCTestCase {
|
|||
var iterator = stream.makeAsyncIterator()
|
||||
let firstValue = await iterator.next()
|
||||
|
||||
XCTAssertEqual(
|
||||
firstValue,
|
||||
false,
|
||||
XCTAssertFalse(
|
||||
try XCTUnwrap(firstValue),
|
||||
"It should yielded the first value correctly"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,10 +185,10 @@ public struct SelectionToolbarItem<Option: Hashable & CaseIterable>: View {
|
|||
DropdownMenuView(
|
||||
options: options,
|
||||
selection: selection,
|
||||
itemLabel: itemLabel,
|
||||
label: {
|
||||
AnyView(style.makeLabel(helpText: helpText ?? style.defaultHelpText))
|
||||
itemLabel: itemLabel
|
||||
) {
|
||||
let helpText = helpText ?? style.defaultHelpText
|
||||
return AnyView(style.makeLabel(helpText: helpText))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import SwiftUI
|
||||
|
||||
/// A text editor with placeholder text support.
|
||||
///
|
||||
/// `PlaceholderTextEditor` extends the standard `TextEditor` by adding placeholder text
|
||||
/// that appears when the editor is empty. The placeholder text is displayed in a secondary
|
||||
/// color and automatically disappears when the user begins typing.
|
||||
public struct PlaceholderTextEditor: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let placeholder: String
|
||||
private var text: Binding<String>
|
||||
private var help: String
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Creates a text editor with placeholder support.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - placeholder: The text to display when the editor is empty.
|
||||
/// - text: A binding to the text being edited.
|
||||
/// - help: The help text displayed on hover.
|
||||
public init(
|
||||
placeholder: String,
|
||||
text: Binding<String>,
|
||||
help: String
|
||||
) {
|
||||
self.placeholder = placeholder
|
||||
self.text = text
|
||||
self.help = help
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
public var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: text)
|
||||
.autocorrectionDisabled(false)
|
||||
.font(.body.monospaced())
|
||||
.textEditorCard()
|
||||
.help(help)
|
||||
|
||||
if text.wrappedValue.isEmpty {
|
||||
Text(placeholder)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.body.monospaced())
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 13)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Without Content") {
|
||||
PlaceholderTextEditor(
|
||||
placeholder: "Caption",
|
||||
text: .constant(""),
|
||||
help: "Add a caption for your image"
|
||||
)
|
||||
.frame(width: 400)
|
||||
}
|
||||
|
||||
#Preview("With Content") {
|
||||
PlaceholderTextEditor(
|
||||
placeholder: "Alt text",
|
||||
text: .constant("Photo of a beautiful beach on a sunny day"),
|
||||
help: "Add descriptive alt text for accessibility"
|
||||
)
|
||||
.frame(width: 400)
|
||||
}
|
||||
|
|
@ -78,14 +78,16 @@ public struct TagListView: View {
|
|||
#Preview("Regular Tags") {
|
||||
TagListView(
|
||||
tags: ["swift", "ios", "macos"],
|
||||
helpText: { "Select \($0)" }
|
||||
) { _ in }
|
||||
helpText: { "Select \($0)" },
|
||||
action: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Remove Tags") {
|
||||
TagListView(
|
||||
tags: ["swift", "ios", "macos"],
|
||||
style: .remove,
|
||||
helpText: { "Remove \($0)" }
|
||||
) { _ in }
|
||||
helpText: { "Remove \($0)" },
|
||||
action: { _ in }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,19 @@ public extension DateFormatter {
|
|||
///
|
||||
/// This formatter produces ISO 8601 compatible date strings with short time
|
||||
/// format, specifically designed for use in weblog entry frontmatter. The
|
||||
/// formatter uses GMT+0 timezone and POSIX locale for consistent formatting
|
||||
/// formatter uses the user timezone and POSIX locale for consistent formatting
|
||||
/// across different system configurations.
|
||||
///
|
||||
/// Output format: `YYYY-MM-DD HH:MM`
|
||||
///
|
||||
/// Example: `2024-03-15 14:30`
|
||||
static let iso8601WithShortTime: DateFormatter = {
|
||||
static func iso8601WithShortTime(
|
||||
timeZone: TimeZone
|
||||
) -> DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.timeZone = timeZone
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public extension String {
|
|||
/// ```
|
||||
func slugified() -> String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .whitespaces)
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "-")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,29 +5,50 @@ public extension String {
|
|||
/// Creates a weblog entry body with frontmatter from the string content.
|
||||
///
|
||||
/// This method formats the string as a weblog entry by adding frontmatter
|
||||
/// with the specified publication date. The resulting format follows the
|
||||
/// with the specified publication date, status, and tags. The resulting format follows the
|
||||
/// OMG.LOL weblog API requirements with ISO 8601 date formatting.
|
||||
///
|
||||
/// The output format is:
|
||||
/// ```
|
||||
/// ---
|
||||
/// Date: YYYY-MM-DD HH:MM
|
||||
/// Status: [status value]
|
||||
/// Tags: Tag1, Tag2, Tag3
|
||||
/// ---
|
||||
///
|
||||
/// [string content]
|
||||
/// ```
|
||||
///
|
||||
/// - Parameter date: The publication date to include in the frontmatter
|
||||
/// - Parameters:
|
||||
/// - date: The publication date to include in the frontmatter
|
||||
/// - timeZone: The timezone used for the publication (defaults to user's current timezone)
|
||||
/// - status: The publication status to include in the frontmatter (e.g., "Draft", "Live", "Feed Only", "Web
|
||||
/// Only", "Unlisted")
|
||||
/// - tags: An array of tags to include in the frontmatter. Tags are comma-separated.
|
||||
/// - Returns: UTF-8 encoded data containing the formatted weblog entry body
|
||||
func weblogEntryBody(with date: Date) -> Data {
|
||||
let formattedString = DateFormatter.iso8601WithShortTime.string(from: date)
|
||||
let body = """
|
||||
func weblogEntryBody(
|
||||
date: Date,
|
||||
timeZone: TimeZone = .current,
|
||||
status: String,
|
||||
tags: [String]
|
||||
) -> Data {
|
||||
let formattedString = DateFormatter
|
||||
.iso8601WithShortTime(timeZone: timeZone)
|
||||
.string(from: date)
|
||||
|
||||
var frontmatter = """
|
||||
---
|
||||
Date: \(formattedString)
|
||||
---
|
||||
|
||||
\(self)
|
||||
Status: \(status)
|
||||
"""
|
||||
return body.data(using: .utf8) ?? Data()
|
||||
|
||||
if !tags.isEmpty {
|
||||
let tagsString = tags.joined(separator: ", ")
|
||||
frontmatter += "\nTags: \(tagsString)"
|
||||
}
|
||||
|
||||
frontmatter += "\n---\n\n\(self)"
|
||||
|
||||
return frontmatter.data(using: .utf8) ?? Data()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@ import Foundation
|
|||
|
||||
public extension URL {
|
||||
|
||||
/// Creates a preview URL by appending `.preview` before the file extension.
|
||||
/// Creates a preview URL by appending `.preview.jpg` to the filename without its extension.
|
||||
///
|
||||
/// This property generates a preview version of an image URL by inserting
|
||||
/// `.preview` between the filename and its extension. This is commonly used
|
||||
/// for generating thumbnail or preview versions of images.
|
||||
/// This property generates a preview version of an image URL. All preview images
|
||||
/// generated by the backend are JPEG format, regardless of the original image format.
|
||||
/// The preview URL is constructed by taking the filename (without extension), appending
|
||||
/// `.preview`, and then adding the `.jpg` extension.
|
||||
///
|
||||
/// - Returns: A new URL with `.preview` inserted before the file extension
|
||||
/// - Example: `https://cdn.some.pics/user/image.jpg` → `https://cdn.some.pics/user/image.preview.jpg`
|
||||
/// - Returns: A new URL with `.preview.jpg` replacing the original file extension
|
||||
/// - Example: `https://cdn.some.pics/user/image.png` → `https://cdn.some.pics/user/image.preview.jpg`
|
||||
var imagePreviewURL: URL {
|
||||
let pathExtension = pathExtension
|
||||
let lastPathComponent = lastPathComponent
|
||||
|
|
@ -19,11 +20,7 @@ public extension URL {
|
|||
lastPathComponent.dropLast(pathExtension.count + (pathExtension.isEmpty ? 0 : 1))
|
||||
)
|
||||
|
||||
let newFilename = if pathExtension.isEmpty {
|
||||
"\(filenameWithoutExtension).preview"
|
||||
} else {
|
||||
"\(filenameWithoutExtension).preview.\(pathExtension)"
|
||||
}
|
||||
let newFilename = "\(filenameWithoutExtension).preview.jpg"
|
||||
|
||||
return baseURL.appendingPathComponent(newFilename)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,5 @@ import Foundation
|
|||
public extension URL {
|
||||
|
||||
/// The Ko-fi donation page URL for supporting the application developer.
|
||||
///
|
||||
/// This static URL provides a centralized reference to the Ko-fi donation page,
|
||||
/// used throughout the application wherever the Tip Jar or donation links are displayed.
|
||||
/// The URL points to the developer's Ko-fi profile where users can make one-time
|
||||
/// donations to support the application development.
|
||||
static let tipJarURL = URL(string: "https://ko-fi.com/Z8Z0C9KPT")!
|
||||
static let tipJarURL = URL(string: "https://ko-fi.com/otaviocc")!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import XCTest
|
||||
import Testing
|
||||
@testable import FoundationExtensions
|
||||
|
||||
final class ArrayContainsTests: XCTestCase {
|
||||
// swiftlint:disable file_length type_body_length
|
||||
|
||||
func test_containsPartial_withMatchingSubstring_returnsTrue() {
|
||||
@Suite("ArrayContains Tests")
|
||||
struct ArrayContainsTests {
|
||||
|
||||
@Test("It should return true when array contains element with matching substring")
|
||||
func containsPartial_withMatchingSubstring_returnsTrue() {
|
||||
// Given
|
||||
let array = ["apple", "banana", "orange"]
|
||||
let partial = "app"
|
||||
|
|
@ -12,13 +16,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when array contains element with matching substring"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withMultipleMatches_returnsTrue() {
|
||||
@Test("It should return true when multiple elements contain the partial string")
|
||||
func containsPartial_withMultipleMatches_returnsTrue() {
|
||||
// Given
|
||||
let array = ["application", "approach", "apple", "banana"]
|
||||
let partial = "app"
|
||||
|
|
@ -27,13 +32,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when multiple elements contain the partial string"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withNoMatches_returnsFalse() {
|
||||
@Test("It should return false when no elements contain the partial string")
|
||||
func containsPartial_withNoMatches_returnsFalse() {
|
||||
// Given
|
||||
let array = ["banana", "orange", "grape"]
|
||||
let partial = "app"
|
||||
|
|
@ -42,13 +48,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when no elements contain the partial string"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withUppercasePartial_returnsTrue() {
|
||||
@Test("It should return true when partial string matches case-insensitively")
|
||||
func containsPartial_withUppercasePartial_returnsTrue() {
|
||||
// Given
|
||||
let array = ["apple", "banana", "orange"]
|
||||
let partial = "APP"
|
||||
|
|
@ -57,13 +64,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial string matches case-insensitively"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withMixedCasePartial_returnsTrue() {
|
||||
@Test("It should return true with mixed case partial string")
|
||||
func containsPartial_withMixedCasePartial_returnsTrue() {
|
||||
// Given
|
||||
let array = ["application", "BANANA", "OrAnGe"]
|
||||
let partial = "aPp"
|
||||
|
|
@ -72,13 +80,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true with mixed case partial string"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withUppercaseArrayElements_returnsTrue() {
|
||||
@Test("It should return true when array elements are uppercase but partial is lowercase")
|
||||
func containsPartial_withUppercaseArrayElements_returnsTrue() {
|
||||
// Given
|
||||
let array = ["APPLE", "BANANA", "ORANGE"]
|
||||
let partial = "app"
|
||||
|
|
@ -87,13 +96,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when array elements are uppercase but partial is lowercase"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withEmptyArray_returnsFalse() {
|
||||
@Test("It should return false when array is empty")
|
||||
func containsPartial_withEmptyArray_returnsFalse() {
|
||||
// Given
|
||||
let array: [String] = []
|
||||
let partial = "app"
|
||||
|
|
@ -102,13 +112,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when array is empty"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withEmptyPartialString_returnsFalse() {
|
||||
@Test("It should return false when partial string is empty (empty string matches nothing)")
|
||||
func containsPartial_withEmptyPartialString_returnsFalse() {
|
||||
// Given
|
||||
let array = ["apple", "banana", "orange"]
|
||||
let partial = ""
|
||||
|
|
@ -117,13 +128,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when partial string is empty (empty string matches nothing)"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withEmptyArrayAndEmptyPartial_returnsFalse() {
|
||||
@Test("It should return false when both array and partial string are empty")
|
||||
func containsPartial_withEmptyArrayAndEmptyPartial_returnsFalse() {
|
||||
// Given
|
||||
let array: [String] = []
|
||||
let partial = ""
|
||||
|
|
@ -132,13 +144,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when both array and partial string are empty"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withEmptyStringInArray_returnsFalse() {
|
||||
@Test("It should return false when array contains empty string and partial is empty (empty string matches nothing)")
|
||||
func containsPartial_withEmptyStringInArray_returnsFalse() {
|
||||
// Given
|
||||
let array = ["apple", "", "banana"]
|
||||
let partial = ""
|
||||
|
|
@ -147,13 +160,16 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when array contains empty string and partial is empty (empty string matches nothing)"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withSearchingInEmptyString_returnsFalse() {
|
||||
@Test(
|
||||
"It should return true when searching for non-empty partial in array with empty string (should match 'apple')"
|
||||
)
|
||||
func containsPartial_withSearchingInEmptyString_returnsFalse() {
|
||||
// Given
|
||||
let array = ["apple", "", "banana"]
|
||||
let partial = "app"
|
||||
|
|
@ -162,13 +178,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when searching for non-empty partial in array with empty string (should match 'apple')"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withWhitespacePartial_returnsTrue() {
|
||||
@Test("It should return true when partial is whitespace and array contains strings with spaces")
|
||||
func containsPartial_withWhitespacePartial_returnsTrue() {
|
||||
// Given
|
||||
let array = ["hello world", "banana", "orange"]
|
||||
let partial = " "
|
||||
|
|
@ -177,13 +194,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial is whitespace and array contains strings with spaces"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withWhitespaceInPartial_returnsTrue() {
|
||||
@Test("It should return true when partial contains whitespace and matches substring with spaces")
|
||||
func containsPartial_withWhitespaceInPartial_returnsTrue() {
|
||||
// Given
|
||||
let array = ["hello world", "banana split", "orange juice"]
|
||||
let partial = "lo wo"
|
||||
|
|
@ -192,13 +210,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial contains whitespace and matches substring with spaces"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withTrailingWhitespace_returnsTrue() {
|
||||
@Test("It should return true when array element has trailing whitespace")
|
||||
func containsPartial_withTrailingWhitespace_returnsTrue() {
|
||||
// Given
|
||||
let array = ["apple ", "banana", "orange"]
|
||||
let partial = "apple"
|
||||
|
|
@ -207,13 +226,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when array element has trailing whitespace"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withLeadingWhitespace_returnsTrue() {
|
||||
@Test("It should return true when array element has leading whitespace")
|
||||
func containsPartial_withLeadingWhitespace_returnsTrue() {
|
||||
// Given
|
||||
let array = [" apple", "banana", "orange"]
|
||||
let partial = "apple"
|
||||
|
|
@ -222,13 +242,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when array element has leading whitespace"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withSpecialCharacters_returnsTrue() {
|
||||
@Test("It should return true when partial contains special characters and matches")
|
||||
func containsPartial_withSpecialCharacters_returnsTrue() {
|
||||
// Given
|
||||
let array = ["test@email.com", "user123", "hello-world"]
|
||||
let partial = "@email"
|
||||
|
|
@ -237,13 +258,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial contains special characters and matches"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withNumbersInPartial_returnsTrue() {
|
||||
@Test("It should return true when partial contains numbers and matches")
|
||||
func containsPartial_withNumbersInPartial_returnsTrue() {
|
||||
// Given
|
||||
let array = ["user123", "test456", "admin"]
|
||||
let partial = "123"
|
||||
|
|
@ -252,13 +274,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial contains numbers and matches"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withUnicodeCharacters_returnsTrue() {
|
||||
@Test("It should return true when partial contains unicode characters and matches")
|
||||
func containsPartial_withUnicodeCharacters_returnsTrue() {
|
||||
// Given
|
||||
let array = ["café", "naïve", "résumé"]
|
||||
let partial = "café"
|
||||
|
|
@ -267,13 +290,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial contains unicode characters and matches"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withEmojiCharacters_returnsTrue() {
|
||||
@Test("It should return true when partial contains emoji and matches")
|
||||
func containsPartial_withEmojiCharacters_returnsTrue() {
|
||||
// Given
|
||||
let array = ["Hello 👋", "Good morning", "Have a nice day"]
|
||||
let partial = "👋"
|
||||
|
|
@ -282,13 +306,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial contains emoji and matches"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withSingleCharacterPartial_returnsTrue() {
|
||||
@Test("It should return true when partial is single character and matches")
|
||||
func containsPartial_withSingleCharacterPartial_returnsTrue() {
|
||||
// Given
|
||||
let array = ["apple", "banana", "orange"]
|
||||
let partial = "a"
|
||||
|
|
@ -297,13 +322,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial is single character and matches"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withSingleCharacterNoMatch_returnsFalse() {
|
||||
@Test("It should return false when single character partial doesn't match any element")
|
||||
func containsPartial_withSingleCharacterNoMatch_returnsFalse() {
|
||||
// Given
|
||||
let array = ["apple", "banana", "orange"]
|
||||
let partial = "z"
|
||||
|
|
@ -312,13 +338,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when single character partial doesn't match any element"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withSingleCharacterArray_returnsTrue() {
|
||||
@Test("It should return true when array contains single characters and partial matches")
|
||||
func containsPartial_withSingleCharacterArray_returnsTrue() {
|
||||
// Given
|
||||
let array = ["a", "b", "c"]
|
||||
let partial = "a"
|
||||
|
|
@ -327,13 +354,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when array contains single characters and partial matches"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withExactMatch_returnsTrue() {
|
||||
@Test("It should return true when partial exactly matches an array element")
|
||||
func containsPartial_withExactMatch_returnsTrue() {
|
||||
// Given
|
||||
let array = ["apple", "banana", "orange"]
|
||||
let partial = "apple"
|
||||
|
|
@ -342,13 +370,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial exactly matches an array element"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withExactMatchDifferentCase_returnsTrue() {
|
||||
@Test("It should return true when partial exactly matches with different case")
|
||||
func containsPartial_withExactMatchDifferentCase_returnsTrue() {
|
||||
// Given
|
||||
let array = ["Apple", "Banana", "Orange"]
|
||||
let partial = "APPLE"
|
||||
|
|
@ -357,13 +386,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial exactly matches with different case"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withAccentedCharacters_returnsFalse() {
|
||||
@Test("It should return false when plain text doesn't match accented characters exactly")
|
||||
func containsPartial_withAccentedCharacters_returnsFalse() {
|
||||
// Given
|
||||
let array = ["café", "résumé", "naïve"]
|
||||
let partial = "cafe"
|
||||
|
|
@ -372,13 +402,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when plain text doesn't match accented characters exactly"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withMatchingAccentedCharacters_returnsTrue() {
|
||||
@Test("It should return true when accented characters match exactly")
|
||||
func containsPartial_withMatchingAccentedCharacters_returnsTrue() {
|
||||
// Given
|
||||
let array = ["café", "résumé", "naïve"]
|
||||
let partial = "café"
|
||||
|
|
@ -387,13 +418,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when accented characters match exactly"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withGermanUmlaut_returnsFalse() {
|
||||
@Test("It should return false when plain text doesn't match umlauts exactly")
|
||||
func containsPartial_withGermanUmlaut_returnsFalse() {
|
||||
// Given
|
||||
let array = ["Müller", "Straße", "Käse"]
|
||||
let partial = "muller"
|
||||
|
|
@ -402,13 +434,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when plain text doesn't match umlauts exactly"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withMatchingUmlaut_returnsTrue() {
|
||||
@Test("It should return true when umlauts match exactly")
|
||||
func containsPartial_withMatchingUmlaut_returnsTrue() {
|
||||
// Given
|
||||
let array = ["Müller", "Straße", "Käse"]
|
||||
let partial = "Müller"
|
||||
|
|
@ -417,13 +450,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when umlauts match exactly"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withCaseInsensitiveUmlaut_returnsTrue() {
|
||||
@Test("It should return true when case-insensitive matching works with umlauts")
|
||||
func containsPartial_withCaseInsensitiveUmlaut_returnsTrue() {
|
||||
// Given
|
||||
let array = ["Müller", "Straße", "Käse"]
|
||||
let partial = "müller"
|
||||
|
|
@ -432,13 +466,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when case-insensitive matching works with umlauts"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withVeryLongString_returnsTrue() {
|
||||
@Test("It should return true when searching in very long strings")
|
||||
func containsPartial_withVeryLongString_returnsTrue() {
|
||||
// Given
|
||||
let longString = String(repeating: "a", count: 1000) + "target" + String(repeating: "b", count: 1000)
|
||||
let array = [longString, "short", "medium"]
|
||||
|
|
@ -448,13 +483,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when searching in very long strings"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withPartialLongerThanElements_returnsFalse() {
|
||||
@Test("It should return false when partial string is longer than all array elements")
|
||||
func containsPartial_withPartialLongerThanElements_returnsFalse() {
|
||||
// Given
|
||||
let array = ["a", "bb", "ccc"]
|
||||
let partial = "longpartialstring"
|
||||
|
|
@ -463,13 +499,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertFalse(
|
||||
result,
|
||||
#expect(
|
||||
!result,
|
||||
"It should return false when partial string is longer than all array elements"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withSubstringAtEnd_returnsTrue() {
|
||||
@Test("It should return true when partial matches at the end of an element")
|
||||
func containsPartial_withSubstringAtEnd_returnsTrue() {
|
||||
// Given
|
||||
let array = ["something", "another", "endapp"]
|
||||
let partial = "app"
|
||||
|
|
@ -478,13 +515,14 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial matches at the end of an element"
|
||||
)
|
||||
}
|
||||
|
||||
func test_containsPartial_withSubstringInMiddle_returnsTrue() {
|
||||
@Test("It should return true when partial matches in the middle of an element")
|
||||
func containsPartial_withSubstringInMiddle_returnsTrue() {
|
||||
// Given
|
||||
let array = ["something", "mapplication", "other"]
|
||||
let partial = "app"
|
||||
|
|
@ -493,9 +531,11 @@ final class ArrayContainsTests: XCTestCase {
|
|||
let result = array.containsPartial(partial)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(
|
||||
#expect(
|
||||
result,
|
||||
"It should return true when partial matches in the middle of an element"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable file_length type_body_length
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import FoundationExtensions
|
||||
|
||||
@Suite("StringSlug Tests")
|
||||
struct StringSlugTests {
|
||||
|
||||
@Test("It should convert simple string with space to slug format")
|
||||
func slugified_withSimpleString_returnsSlugFormat() {
|
||||
// Given
|
||||
let input = "Hello World"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "Hello-World",
|
||||
"It should convert simple string with space to slug format"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should trim leading and trailing whitespace")
|
||||
func slugified_withLeadingTrailingWhitespace_trimsWhitespace() {
|
||||
// Given
|
||||
let input = " multiple spaces "
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "multiple-spaces",
|
||||
"It should trim leading and trailing whitespace"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle already slugged strings")
|
||||
func slugified_withAlreadySluggedString_returnsSameFormat() {
|
||||
// Given
|
||||
let input = "already-slugged"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "already-slugged",
|
||||
"It should handle already slugged strings"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle multiple consecutive spaces")
|
||||
func slugified_withMultipleSpaces_replacesWithSingleHyphen() {
|
||||
// Given
|
||||
let input = "multiple spaces here"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "multiple-spaces-here",
|
||||
"It should handle multiple consecutive spaces"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle tabs and newlines as whitespace")
|
||||
func slugified_withTabsAndNewlines_replacesWithHyphens() {
|
||||
// Given
|
||||
let input = "hello\tworld\nhere"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "hello-world-here",
|
||||
"It should handle tabs and newlines as whitespace"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle empty string")
|
||||
func slugified_withEmptyString_returnsEmptyString() {
|
||||
// Given
|
||||
let input = ""
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result.isEmpty,
|
||||
"It should handle empty string"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle string with only whitespace")
|
||||
func slugified_withOnlyWhitespace_returnsEmptyString() {
|
||||
// Given
|
||||
let input = " \t\n "
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result.isEmpty,
|
||||
"It should handle string with only whitespace"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle single word")
|
||||
func slugified_withSingleWord_returnsWord() {
|
||||
// Given
|
||||
let input = "Hello"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "Hello",
|
||||
"It should handle single word"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should preserve special characters in words")
|
||||
func slugified_withSpecialCharacters_preservesCharacters() {
|
||||
// Given
|
||||
let input = "test@email.com user123"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "test@email.com-user123",
|
||||
"It should preserve special characters in words"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle unicode characters")
|
||||
func slugified_withUnicodeCharacters_preservesUnicode() {
|
||||
// Given
|
||||
let input = "café naïve résumé"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "café-naïve-résumé",
|
||||
"It should handle unicode characters"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle emoji characters")
|
||||
func slugified_withEmojiCharacters_preservesEmoji() {
|
||||
// Given
|
||||
let input = "Hello 👋 World 🌟"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "Hello-👋-World-🌟",
|
||||
"It should handle emoji characters"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle mixed case strings")
|
||||
func slugified_withMixedCase_preservesCase() {
|
||||
// Given
|
||||
let input = "Hello World Test"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "Hello-World-Test",
|
||||
"It should handle mixed case strings"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle string starting with whitespace")
|
||||
func slugified_withLeadingWhitespace_trimsLeadingWhitespace() {
|
||||
// Given
|
||||
let input = " Hello World"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "Hello-World",
|
||||
"It should handle string starting with whitespace"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle string ending with whitespace")
|
||||
func slugified_withTrailingWhitespace_trimsTrailingWhitespace() {
|
||||
// Given
|
||||
let input = "Hello World "
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "Hello-World",
|
||||
"It should handle string ending with whitespace"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle string with single space")
|
||||
func slugified_withSingleSpace_replacesWithHyphen() {
|
||||
// Given
|
||||
let input = "Hello World"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "Hello-World",
|
||||
"It should handle string with single space"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle numbers in string")
|
||||
func slugified_withNumbers_preservesNumbers() {
|
||||
// Given
|
||||
let input = "test 123 456"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "test-123-456",
|
||||
"It should handle numbers in string"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle punctuation in words")
|
||||
func slugified_withPunctuation_preservesPunctuation() {
|
||||
// Given
|
||||
let input = "hello-world test.com"
|
||||
|
||||
// When
|
||||
let result = input.slugified()
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
result == "hello-world-test.com",
|
||||
"It should handle punctuation in words"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import FoundationExtensions
|
||||
|
||||
// swiftlint:disable file_length type_body_length
|
||||
|
||||
@Suite("StringWeblog Tests")
|
||||
struct StringWeblogTests {
|
||||
|
||||
@Test("It should create weblog entry body with frontmatter including date and status")
|
||||
func weblogEntryBody_withDateAndStatus_createsFrontmatter() throws {
|
||||
// Given
|
||||
let content = "This is my blog post content"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
---
|
||||
|
||||
This is my blog post content
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should create weblog entry body with frontmatter including date and status"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should include tags in frontmatter when tags are provided")
|
||||
func weblogEntryBody_withTags_includesTagsInFrontmatter() throws {
|
||||
// Given
|
||||
let content = "Blog post with tags"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags = ["swift", "ios", "development"]
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
Tags: swift, ios, development
|
||||
---
|
||||
|
||||
Blog post with tags
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should include tags in frontmatter when tags are provided"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should not include tags line when tags array is empty")
|
||||
func weblogEntryBody_withEmptyTags_omitsTagsLine() throws {
|
||||
// Given
|
||||
let content = "Blog post without tags"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
---
|
||||
|
||||
Blog post without tags
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should not include tags line when tags array is empty"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should format date correctly using ISO 8601 with short time")
|
||||
func weblogEntryBody_withSpecificDate_formatsDateCorrectly() throws {
|
||||
// Given
|
||||
let content = "Test content"
|
||||
let date = Date(timeIntervalSince1970: 1_704_153_600)
|
||||
let status = "Draft"
|
||||
let tags: [String] = []
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-02 00:00
|
||||
Status: Draft
|
||||
---
|
||||
|
||||
Test content
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should format date correctly using ISO 8601 with short time"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle different status values")
|
||||
func weblogEntryBody_withDifferentStatus_includesStatus() throws {
|
||||
// Given
|
||||
let content = "Test content"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Feed Only"
|
||||
let tags: [String] = []
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Feed Only
|
||||
---
|
||||
|
||||
Test content
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should handle different status values"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle multiple tags with proper comma separation")
|
||||
func weblogEntryBody_withMultipleTags_separatesWithCommas() throws {
|
||||
// Given
|
||||
let content = "Multi-tag post"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags = ["tag1", "tag2", "tag3", "tag4"]
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
Tags: tag1, tag2, tag3, tag4
|
||||
---
|
||||
|
||||
Multi-tag post
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should handle multiple tags with proper comma separation"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle single tag")
|
||||
func weblogEntryBody_withSingleTag_includesTag() throws {
|
||||
// Given
|
||||
let content = "Single tag post"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags = ["swift"]
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
Tags: swift
|
||||
---
|
||||
|
||||
Single tag post
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should handle single tag"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should preserve content exactly as provided")
|
||||
func weblogEntryBody_withContent_preservesContent() throws {
|
||||
// Given
|
||||
let content = "This is my\nmultiline\nblog post content"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
---
|
||||
|
||||
This is my
|
||||
multiline
|
||||
blog post content
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should preserve content exactly as provided"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should return UTF-8 encoded data")
|
||||
func weblogEntryBody_returnsUTF8EncodedData() throws {
|
||||
// Given
|
||||
let content = "Test content"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
!resultString.isEmpty,
|
||||
"It should return UTF-8 encoded data"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle empty content string")
|
||||
func weblogEntryBody_withEmptyContent_includesEmptyContent() throws {
|
||||
// Given
|
||||
let content = ""
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
---
|
||||
|
||||
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should handle empty content string"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle tags with special characters")
|
||||
func weblogEntryBody_withSpecialCharacterTags_preservesCharacters() throws {
|
||||
// Given
|
||||
let content = "Test content"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags = ["swift-ios", "test@example", "tag_123"]
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
Tags: swift-ios, test@example, tag_123
|
||||
---
|
||||
|
||||
Test content
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should handle tags with special characters"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle unicode characters in content")
|
||||
func weblogEntryBody_withUnicodeContent_preservesUnicode() throws {
|
||||
// Given
|
||||
let content = "Café & Naïve résumé 🌟"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
---
|
||||
|
||||
Café & Naïve résumé 🌟
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should handle unicode characters in content"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should have correct frontmatter structure")
|
||||
func weblogEntryBody_hasCorrectFrontmatterStructure() throws {
|
||||
// Given
|
||||
let content = "Test content"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags = ["tag1", "tag2"]
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
Tags: tag1, tag2
|
||||
---
|
||||
|
||||
Test content
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should have correct frontmatter structure"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle markdown content")
|
||||
func weblogEntryBody_withMarkdownContent_preservesMarkdown() throws {
|
||||
// Given
|
||||
let content = "# Title\n\nThis is **bold** and *italic*"
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200)
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
let expected = """
|
||||
---
|
||||
Date: 2024-01-01 00:00
|
||||
Status: Live
|
||||
---
|
||||
|
||||
# Title
|
||||
|
||||
This is **bold** and *italic*
|
||||
"""
|
||||
|
||||
// When
|
||||
let result = try content.weblogEntryBody(
|
||||
date: date,
|
||||
timeZone: #require(TimeZone(secondsFromGMT: 0)),
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
// Then
|
||||
let resultString = try #require(
|
||||
String(data: result, encoding: .utf8)
|
||||
)
|
||||
|
||||
#expect(
|
||||
resultString == expected,
|
||||
"It should handle markdown content"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable file_length type_body_length
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import FoundationExtensions
|
||||
|
||||
@Suite("URLAddress Tests")
|
||||
struct URLAddressTests {
|
||||
|
||||
@Test("It should create webpage URL for OMG.LOL address")
|
||||
func webpageFor_withAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "alice"
|
||||
|
||||
// When
|
||||
let url = URL(webpageFor: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://alice.omg.lol",
|
||||
"It should create webpage URL for OMG.LOL address"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create now page URL for OMG.LOL address")
|
||||
func nowPageFor_withAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "bob"
|
||||
|
||||
// When
|
||||
let url = URL(nowPageFor: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://bob.omg.lol/now",
|
||||
"It should create now page URL for OMG.LOL address"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create weblog URL for OMG.LOL address")
|
||||
func weblogFor_withAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "charlie"
|
||||
|
||||
// When
|
||||
let url = URL(weblogFor: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://charlie.weblog.lol",
|
||||
"It should create weblog URL for OMG.LOL address"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create weblog post URL with location")
|
||||
func weblogPostFor_withAddressAndLocation_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "dave"
|
||||
let location = "/my-post"
|
||||
|
||||
// When
|
||||
let url = URL(weblogPostFor: address, location: location)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://dave.weblog.lol/my-post",
|
||||
"It should create weblog post URL with location"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create avatar URL for OMG.LOL address")
|
||||
func avatarFor_withAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "eve"
|
||||
|
||||
// When
|
||||
let url = URL(avatarFor: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://profiles.cache.lol/eve/picture",
|
||||
"It should create avatar URL for OMG.LOL address"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create status URL with status ID and address")
|
||||
func statusID_withStatusIDAndAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let statusID = "abc123"
|
||||
let address = "frank"
|
||||
|
||||
// When
|
||||
let url = URL(statusID: statusID, for: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://frank.status.lol/abc123",
|
||||
"It should create status URL with status ID and address"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create PURL URL with PURL name and address")
|
||||
func purlName_withPURLNameAndAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let purlName = "github"
|
||||
let address = "grace"
|
||||
|
||||
// When
|
||||
let url = URL(purlName: purlName, for: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://grace.url.lol/github",
|
||||
"It should create PURL URL with PURL name and address"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create paste URL with paste title and address")
|
||||
func pasteTitle_withPasteTitleAndAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let pasteTitle = "my-code"
|
||||
let address = "henry"
|
||||
|
||||
// When
|
||||
let url = URL(pasteTitle: pasteTitle, for: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://henry.paste.lol/my-code",
|
||||
"It should create paste URL with paste title and address"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create some.pics URL for OMG.LOL address")
|
||||
func somePicsFor_withAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "iris"
|
||||
|
||||
// When
|
||||
let url = URL(somePicsFor: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://iris.some.pics",
|
||||
"It should create some.pics URL for OMG.LOL address"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle address with numbers")
|
||||
func webpageFor_withNumericAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "user123"
|
||||
|
||||
// When
|
||||
let url = URL(webpageFor: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://user123.omg.lol",
|
||||
"It should handle address with numbers"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle address with hyphens")
|
||||
func webpageFor_withHyphenatedAddress_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "user-name"
|
||||
|
||||
// When
|
||||
let url = URL(webpageFor: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://user-name.omg.lol",
|
||||
"It should handle address with hyphens"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle weblog post location with path")
|
||||
func weblogPostFor_withPathLocation_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "alice"
|
||||
let location = "/2024/01/my-blog-post"
|
||||
|
||||
// When
|
||||
let url = URL(weblogPostFor: address, location: location)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://alice.weblog.lol/2024/01/my-blog-post",
|
||||
"It should handle weblog post location with path"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle status ID with special characters")
|
||||
func statusID_withSpecialCharacters_createsCorrectURL() {
|
||||
// Given
|
||||
let statusID = "abc-123_xyz"
|
||||
let address = "bob"
|
||||
|
||||
// When
|
||||
let url = URL(statusID: statusID, for: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://bob.status.lol/abc-123_xyz",
|
||||
"It should handle status ID with special characters"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle PURL name with special characters")
|
||||
func purlName_withSpecialCharacters_createsCorrectURL() {
|
||||
// Given
|
||||
let purlName = "my-purl_name"
|
||||
let address = "charlie"
|
||||
|
||||
// When
|
||||
let url = URL(purlName: purlName, for: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://charlie.url.lol/my-purl_name",
|
||||
"It should handle PURL name with special characters"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle paste title with special characters")
|
||||
func pasteTitle_withSpecialCharacters_createsCorrectURL() {
|
||||
// Given
|
||||
let pasteTitle = "my-code-snippet"
|
||||
let address = "dave"
|
||||
|
||||
// When
|
||||
let url = URL(pasteTitle: pasteTitle, for: address)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://dave.paste.lol/my-code-snippet",
|
||||
"It should handle paste title with special characters"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should create nowGardenURL static property")
|
||||
func nowGardenURL_returnsCorrectURL() {
|
||||
// When
|
||||
let url = URL.nowGardenURL
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://now.garden/",
|
||||
"It should create nowGardenURL static property"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle empty weblog location")
|
||||
func weblogPostFor_withEmptyLocation_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "eve"
|
||||
let location = ""
|
||||
|
||||
// When
|
||||
let url = URL(weblogPostFor: address, location: location)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://eve.weblog.lol",
|
||||
"It should handle empty weblog location"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("It should handle weblog location without leading slash")
|
||||
func weblogPostFor_withLocationWithoutSlash_createsCorrectURL() {
|
||||
// Given
|
||||
let address = "frank"
|
||||
let location = "/my-post"
|
||||
|
||||
// When
|
||||
let url = URL(weblogPostFor: address, location: location)
|
||||
|
||||
// Then
|
||||
#expect(
|
||||
url.absoluteString == "https://frank.weblog.lol/my-post",
|
||||
"It should handle weblog location without leading slash"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import FoundationExtensions
|
||||
|
||||
final class URLImagePreviewTests: XCTestCase {
|
||||
@Suite("URLImagePreview Tests")
|
||||
struct URLImagePreviewTests {
|
||||
|
||||
func test_imagePreviewURL_withStandardImageURL_insertsPreviewBeforeExtension() {
|
||||
@Test("It should replace the file extension with .preview.jpg")
|
||||
func imagePreviewURL_withStandardImageURL_insertsPreviewBeforeExtension() {
|
||||
// Given
|
||||
let url = URL(string: "https://cdn.some.pics/user/image.jpg")!
|
||||
|
||||
|
|
@ -11,14 +14,14 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.absoluteString,
|
||||
"https://cdn.some.pics/user/image.preview.jpg",
|
||||
"It should insert .preview before the file extension"
|
||||
#expect(
|
||||
result.absoluteString == "https://cdn.some.pics/user/image.preview.jpg",
|
||||
"It should replace the file extension with .preview.jpg"
|
||||
)
|
||||
}
|
||||
|
||||
func test_imagePreviewURL_withPNGImage_insertsPreviewBeforeExtension() {
|
||||
@Test("It should replace PNG extension with .preview.jpg")
|
||||
func imagePreviewURL_withPNGImage_insertsPreviewBeforeExtension() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com/photos/sunset.png")!
|
||||
|
||||
|
|
@ -26,14 +29,14 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.absoluteString,
|
||||
"https://example.com/photos/sunset.preview.png",
|
||||
"It should insert .preview before PNG extension"
|
||||
#expect(
|
||||
result.absoluteString == "https://example.com/photos/sunset.preview.jpg",
|
||||
"It should replace PNG extension with .preview.jpg"
|
||||
)
|
||||
}
|
||||
|
||||
func test_imagePreviewURL_withNoExtension_appendsPreview() {
|
||||
@Test("It should append .preview.jpg when there is no file extension")
|
||||
func imagePreviewURL_withNoExtension_appendsPreview() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com/images/photo")!
|
||||
|
||||
|
|
@ -41,14 +44,14 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.absoluteString,
|
||||
"https://example.com/images/photo.preview",
|
||||
"It should append .preview when there is no file extension"
|
||||
#expect(
|
||||
result.absoluteString == "https://example.com/images/photo.preview.jpg",
|
||||
"It should append .preview.jpg when there is no file extension"
|
||||
)
|
||||
}
|
||||
|
||||
func test_imagePreviewURL_withComplexFilename_insertsPreviewCorrectly() {
|
||||
@Test("It should replace the extension with .preview.jpg for complex filenames")
|
||||
func imagePreviewURL_withComplexFilename_insertsPreviewCorrectly() {
|
||||
// Given
|
||||
let url = URL(string: "https://cdn.example.com/user-uploads/my-vacation-photo.jpeg")!
|
||||
|
||||
|
|
@ -56,14 +59,14 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.absoluteString,
|
||||
"https://cdn.example.com/user-uploads/my-vacation-photo.preview.jpeg",
|
||||
"It should handle complex filenames with hyphens"
|
||||
#expect(
|
||||
result.absoluteString == "https://cdn.example.com/user-uploads/my-vacation-photo.preview.jpg",
|
||||
"It should replace the extension with .preview.jpg for complex filenames"
|
||||
)
|
||||
}
|
||||
|
||||
func test_imagePreviewURL_withQueryParameters_preservesQuery() {
|
||||
@Test("It should preserve query parameters and use .preview.jpg extension")
|
||||
func imagePreviewURL_withQueryParameters_preservesQuery() {
|
||||
// Given
|
||||
let url = URL(string: "https://api.example.com/images/photo.jpg?size=large&quality=high")!
|
||||
|
||||
|
|
@ -71,14 +74,14 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.absoluteString,
|
||||
"https://api.example.com/images/photo.preview.jpg?size=large&quality=high",
|
||||
"It should preserve query parameters in the preview URL"
|
||||
#expect(
|
||||
result.absoluteString == "https://api.example.com/images/photo.preview.jpg?size=large&quality=high",
|
||||
"It should preserve query parameters and use .preview.jpg extension"
|
||||
)
|
||||
}
|
||||
|
||||
func test_imagePreviewURL_withNestedDirectories_maintainsDirectoryStructure() {
|
||||
@Test("It should maintain the full directory structure and use .preview.jpg extension")
|
||||
func imagePreviewURL_withNestedDirectories_maintainsDirectoryStructure() {
|
||||
// Given
|
||||
let url = URL(string: "https://storage.example.com/users/123/albums/vacation/beach.gif")!
|
||||
|
||||
|
|
@ -86,14 +89,14 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.absoluteString,
|
||||
"https://storage.example.com/users/123/albums/vacation/beach.preview.gif",
|
||||
"It should maintain the full directory structure"
|
||||
#expect(
|
||||
result.absoluteString == "https://storage.example.com/users/123/albums/vacation/beach.preview.jpg",
|
||||
"It should maintain the full directory structure and use .preview.jpg extension"
|
||||
)
|
||||
}
|
||||
|
||||
func test_imagePreviewURL_withLocalFileURL_insertsPreviewCorrectly() {
|
||||
@Test("It should work with local file URLs and use .preview.jpg extension")
|
||||
func imagePreviewURL_withLocalFileURL_insertsPreviewCorrectly() {
|
||||
// Given
|
||||
let url = URL(fileURLWithPath: "/Users/test/Documents/screenshot.png")
|
||||
|
||||
|
|
@ -101,14 +104,14 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.path,
|
||||
"/Users/test/Documents/screenshot.preview.png",
|
||||
"It should work with local file URLs"
|
||||
#expect(
|
||||
result.path == "/Users/test/Documents/screenshot.preview.jpg",
|
||||
"It should work with local file URLs and use .preview.jpg extension"
|
||||
)
|
||||
}
|
||||
|
||||
func test_imagePreviewURL_withMultipleDots_insertsPreviewBeforeLastExtension() {
|
||||
@Test("It should replace the last extension with .preview.jpg when multiple dots exist")
|
||||
func imagePreviewURL_withMultipleDots_insertsPreviewBeforeLastExtension() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com/files/image.backup.jpg")!
|
||||
|
||||
|
|
@ -116,14 +119,14 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.absoluteString,
|
||||
"https://example.com/files/image.backup.preview.jpg",
|
||||
"It should insert .preview before the last extension when multiple dots exist"
|
||||
#expect(
|
||||
result.absoluteString == "https://example.com/files/image.backup.preview.jpg",
|
||||
"It should replace the last extension with .preview.jpg when multiple dots exist"
|
||||
)
|
||||
}
|
||||
|
||||
func test_imagePreviewURL_withSingleCharacterFilename_insertsPreviewCorrectly() {
|
||||
@Test("It should handle single character filenames and use .preview.jpg extension")
|
||||
func imagePreviewURL_withSingleCharacterFilename_insertsPreviewCorrectly() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com/a.jpg")!
|
||||
|
||||
|
|
@ -131,10 +134,9 @@ final class URLImagePreviewTests: XCTestCase {
|
|||
let result = url.imagePreviewURL
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result.absoluteString,
|
||||
"https://example.com/a.preview.jpg",
|
||||
"It should handle single character filenames"
|
||||
#expect(
|
||||
result.absoluteString == "https://example.com/a.preview.jpg",
|
||||
"It should handle single character filenames and use .preview.jpg extension"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import FoundationExtensions
|
||||
|
||||
final class URLMarkdownTests: XCTestCase {
|
||||
@Suite("URLMarkdown Tests")
|
||||
struct URLMarkdownTests {
|
||||
|
||||
func test_markdownFormatted_withTitleAndDefaultIsImage_returnsMarkdownLink() {
|
||||
@Test("It should return a markdown link with custom title")
|
||||
func markdownFormatted_withTitleAndDefaultIsImage_returnsMarkdownLink() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com")!
|
||||
let title = "Example Website"
|
||||
|
|
@ -12,14 +15,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"[Example Website](https://example.com)",
|
||||
#expect(
|
||||
result == "[Example Website](https://example.com)",
|
||||
"It should return a markdown link with custom title"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withTitleAndIsImageFalse_returnsMarkdownLink() {
|
||||
@Test("It should return a markdown link when isImage is explicitly false")
|
||||
func markdownFormatted_withTitleAndIsImageFalse_returnsMarkdownLink() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com")!
|
||||
let title = "Example Website"
|
||||
|
|
@ -28,14 +31,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title, isImage: false)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"[Example Website](https://example.com)",
|
||||
#expect(
|
||||
result == "[Example Website](https://example.com)",
|
||||
"It should return a markdown link when isImage is explicitly false"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withTitleAndIsImageTrue_returnsMarkdownImage() {
|
||||
@Test("It should return a markdown image with custom alt text")
|
||||
func markdownFormatted_withTitleAndIsImageTrue_returnsMarkdownImage() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com/image.jpg")!
|
||||
let title = "My Image"
|
||||
|
|
@ -44,14 +47,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title, isImage: true)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"",
|
||||
#expect(
|
||||
result == "",
|
||||
"It should return a markdown image with custom alt text"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withNoTitleAndDefaultIsImage_returnsMarkdownLinkWithURL() {
|
||||
@Test("It should return a markdown link using URL as both title and link when no title provided")
|
||||
func markdownFormatted_withNoTitleAndDefaultIsImage_returnsMarkdownLinkWithURL() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com")!
|
||||
|
||||
|
|
@ -59,14 +62,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"[https://example.com](https://example.com)",
|
||||
#expect(
|
||||
result == "[https://example.com](https://example.com)",
|
||||
"It should return a markdown link using URL as both title and link when no title provided"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withNoTitleAndIsImageTrue_returnsMarkdownImageWithURL() {
|
||||
@Test("It should return a markdown image using URL as both alt text and source when no title provided")
|
||||
func markdownFormatted_withNoTitleAndIsImageTrue_returnsMarkdownImageWithURL() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com/photo.png")!
|
||||
|
||||
|
|
@ -74,14 +77,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(isImage: true)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"",
|
||||
#expect(
|
||||
result == "",
|
||||
"It should return a markdown image using URL as both alt text and source when no title provided"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withEmptyTitle_returnsMarkdownLinkWithURL() {
|
||||
@Test("It should return a markdown link with empty title text when empty string provided")
|
||||
func markdownFormatted_withEmptyTitle_returnsMarkdownLinkWithURL() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com")!
|
||||
let title = ""
|
||||
|
|
@ -90,14 +93,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"[](https://example.com)",
|
||||
#expect(
|
||||
result == "[](https://example.com)",
|
||||
"It should return a markdown link with empty title text when empty string provided"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withEmptyTitleAndIsImageTrue_returnsMarkdownImageWithEmptyAlt() {
|
||||
@Test("It should return a markdown image with empty alt text when empty string provided")
|
||||
func markdownFormatted_withEmptyTitleAndIsImageTrue_returnsMarkdownImageWithEmptyAlt() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com/image.gif")!
|
||||
let title = ""
|
||||
|
|
@ -106,14 +109,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title, isImage: true)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"",
|
||||
#expect(
|
||||
result == "",
|
||||
"It should return a markdown image with empty alt text when empty string provided"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withComplexURL_returnsCorrectMarkdownLink() {
|
||||
@Test("It should handle complex URLs with paths, queries, and fragments")
|
||||
func markdownFormatted_withComplexURL_returnsCorrectMarkdownLink() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com/path/to/resource?query=value&other=param#section")!
|
||||
let title = "Complex URL"
|
||||
|
|
@ -122,14 +125,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"[Complex URL](https://example.com/path/to/resource?query=value&other=param#section)",
|
||||
#expect(
|
||||
result == "[Complex URL](https://example.com/path/to/resource?query=value&other=param#section)",
|
||||
"It should handle complex URLs with paths, queries, and fragments"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withLocalFileURL_returnsCorrectMarkdownLink() {
|
||||
@Test("It should handle local file URLs correctly")
|
||||
func markdownFormatted_withLocalFileURL_returnsCorrectMarkdownLink() {
|
||||
// Given
|
||||
let url = URL(fileURLWithPath: "/Users/test/document.pdf")
|
||||
let title = "Local Document"
|
||||
|
|
@ -138,14 +141,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"[Local Document](file:///Users/test/document.pdf)",
|
||||
#expect(
|
||||
result == "[Local Document](file:///Users/test/document.pdf)",
|
||||
"It should handle local file URLs correctly"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withSpecialCharactersInTitle_returnsMarkdownLinkWithSpecialChars() {
|
||||
@Test("It should preserve special characters in title without escaping")
|
||||
func markdownFormatted_withSpecialCharactersInTitle_returnsMarkdownLinkWithSpecialChars() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com")!
|
||||
let title = "Special [Characters] & Symbols"
|
||||
|
|
@ -154,14 +157,14 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"[Special [Characters] & Symbols](https://example.com)",
|
||||
#expect(
|
||||
result == "[Special [Characters] & Symbols](https://example.com)",
|
||||
"It should preserve special characters in title without escaping"
|
||||
)
|
||||
}
|
||||
|
||||
func test_markdownFormatted_withUnicodeInTitle_returnsMarkdownLinkWithUnicode() {
|
||||
@Test("It should handle unicode characters and emojis in title correctly")
|
||||
func markdownFormatted_withUnicodeInTitle_returnsMarkdownLinkWithUnicode() {
|
||||
// Given
|
||||
let url = URL(string: "https://example.com")!
|
||||
let title = "Café & Naïve résumé 🌟"
|
||||
|
|
@ -170,9 +173,8 @@ final class URLMarkdownTests: XCTestCase {
|
|||
let result = url.markdownFormatted(title: title, isImage: true)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
"",
|
||||
#expect(
|
||||
result == "",
|
||||
"It should handle unicode characters and emojis in title correctly"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ struct NowEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: NowNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: NowPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -113,4 +114,5 @@ struct NowEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let now = Now(
|
||||
listed: true,
|
||||
markdown: "Foobar \(i)",
|
||||
markdown: "Foobar \(index)",
|
||||
submitted: true,
|
||||
timestamp: 123_123 * Double(i),
|
||||
timestamp: 123_123 * Double(index),
|
||||
address: "otaviocc"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
func fetchNowPage(for address: String) async throws {}
|
||||
func updateNowPage(address: String, content: String, listed: Bool) async throws {}
|
||||
func fetchNowPage(
|
||||
for address: String
|
||||
) async throws {}
|
||||
|
||||
func updateNowPage(
|
||||
address: String,
|
||||
content: String,
|
||||
listed: Bool
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchNowPage() async throws {}
|
||||
func updateNowPage(address: String, content: String, isListed: Bool) async throws {}
|
||||
|
||||
func updateNowPage(
|
||||
address: String,
|
||||
content: String,
|
||||
isListed: Bool
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import XCTest
|
|||
|
||||
final class NowTests: XCTestCase {
|
||||
|
||||
// swiftlint:disable:next empty_xctest_method
|
||||
func testExample() throws {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,4 @@ public struct CreateOrUpdatePasteRequest: Encodable, Sendable {
|
|||
let title: String
|
||||
let content: String
|
||||
let listed: Bool
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
title: String,
|
||||
content: String,
|
||||
listed: Bool
|
||||
) {
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.listed = listed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,4 @@ public struct CreatePURLRequest: Encodable, Sendable {
|
|||
let address: String
|
||||
let name: String
|
||||
let url: String
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
address: String,
|
||||
name: String,
|
||||
url: String
|
||||
) {
|
||||
self.address = address
|
||||
self.name = name
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,4 @@ public struct UpdateNowPageRequest: Encodable, Sendable {
|
|||
|
||||
let content: String
|
||||
let listed: Int
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
content: String,
|
||||
listed: Int
|
||||
) {
|
||||
self.content = content
|
||||
self.listed = listed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,4 @@ public struct UpdateWebpageRequest: Encodable, Sendable {
|
|||
|
||||
let content: String
|
||||
let publish: Bool
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
content: String,
|
||||
publish: Bool
|
||||
) {
|
||||
self.content = content
|
||||
self.publish = publish
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,4 @@ public struct UploadPictureRequest: Encodable, Sendable {
|
|||
// MARK: - Properties
|
||||
|
||||
let pic: String
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
pic: String
|
||||
) {
|
||||
self.pic = pic
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,13 +83,14 @@ public enum WeblogRequestFactory {
|
|||
/// Creates a request to create a new weblog entry.
|
||||
///
|
||||
/// This method builds a POST request to create a new weblog entry with the
|
||||
/// specified content and publication date. The request formats the content
|
||||
/// with frontmatter containing the date and sends it as raw data to the API.
|
||||
/// specified content, publication date, and status. The request formats the content
|
||||
/// with frontmatter containing the date and status, then sends it as raw data to the API.
|
||||
///
|
||||
/// The content is formatted as:
|
||||
/// ```
|
||||
/// ---
|
||||
/// Date: YYYY-MM-DD HH:MM
|
||||
/// Status: [status value]
|
||||
/// ---
|
||||
///
|
||||
/// [content goes here]
|
||||
|
|
@ -97,19 +98,28 @@ public enum WeblogRequestFactory {
|
|||
///
|
||||
/// This request requires authentication as it creates content on behalf
|
||||
/// of the authenticated user. The API will generate a unique entry ID
|
||||
/// and URL slug based on the content title.
|
||||
/// and URL slug based on the content title. The entry's visibility and
|
||||
/// distribution will be controlled by the specified status.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - address: The user address (username) to create the entry for
|
||||
/// - content: The markdown content body of the weblog entry
|
||||
/// - status: The publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only", "Unlisted")
|
||||
/// - tags: An array of tags associated with the entry
|
||||
/// - date: The publication date for the entry
|
||||
/// - Returns: A configured network request for creating a weblog entry
|
||||
public static func makeCreateWeblogEntryRequest(
|
||||
address: String,
|
||||
content: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) -> NetworkRequest<Data, CreateOrUpdateWeblogEntryResponse> {
|
||||
let body = content.weblogEntryBody(with: date)
|
||||
let body = content.weblogEntryBody(
|
||||
date: date,
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
return .init(
|
||||
path: "/address/\(address)/weblog/entry",
|
||||
|
|
@ -122,12 +132,13 @@ public enum WeblogRequestFactory {
|
|||
///
|
||||
/// This method builds a POST request to update an existing weblog entry
|
||||
/// identified by its entry ID. The request formats the updated content
|
||||
/// with frontmatter containing the new date and sends it as raw data.
|
||||
/// with frontmatter containing the new date and status, then sends it as raw data.
|
||||
///
|
||||
/// The content is formatted as:
|
||||
/// ```
|
||||
/// ---
|
||||
/// Date: YYYY-MM-DD HH:MM
|
||||
/// Status: [status value]
|
||||
/// ---
|
||||
///
|
||||
/// [updated content goes here]
|
||||
|
|
@ -135,21 +146,31 @@ public enum WeblogRequestFactory {
|
|||
///
|
||||
/// This request requires authentication and the user must own the entry
|
||||
/// being updated. The entry's URL and slug will remain unchanged, but
|
||||
/// the content and metadata will be updated with the new values.
|
||||
/// the content, publication date, status, and metadata will be updated
|
||||
/// with the new values.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - address: The user address (username) who owns the entry
|
||||
/// - entryID: The unique identifier of the entry to update
|
||||
/// - content: The updated markdown content body of the weblog entry
|
||||
/// - status: The updated publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only",
|
||||
/// "Unlisted")
|
||||
/// - tags: An array of tags associated with the entry
|
||||
/// - date: The updated publication date for the entry
|
||||
/// - Returns: A configured network request for updating the weblog entry
|
||||
public static func makeUpdateWeblogEntryRequest(
|
||||
address: String,
|
||||
entryID: String,
|
||||
content: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) -> NetworkRequest<Data, CreateOrUpdateWeblogEntryResponse> {
|
||||
let body = content.weblogEntryBody(with: date)
|
||||
let body = content.weblogEntryBody(
|
||||
date: date,
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
return .init(
|
||||
path: "/address/\(address)/weblog/entry/\(entryID)",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public extension AccountInformationResponse {
|
|||
|
||||
public let message: String
|
||||
public let email: String
|
||||
public let name: String
|
||||
public let name: String?
|
||||
public let created: Created
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
public struct WeblogEntryResponse: Decodable, Identifiable, Sendable {
|
||||
|
||||
// MARK: - Nested types
|
||||
|
|
@ -30,4 +32,23 @@ public struct WeblogEntryResponse: Decodable, Identifiable, Sendable {
|
|||
public let body: String
|
||||
public let output: String
|
||||
public let metadata: String
|
||||
|
||||
/// Extracts tags from the metadata JSON string.
|
||||
///
|
||||
/// The metadata field contains a JSON string with entry metadata which might include tags.
|
||||
/// Tags are stored as a dictionary where keys are lowercase slugs and values
|
||||
/// preserve the original case (e.g., `{"tags":{"foo":"Foo","bar":"Bar"}}`).
|
||||
///
|
||||
/// - Returns: An array of tag strings, preserving original case. Returns empty array if parsing fails or no tags
|
||||
/// exist.
|
||||
public var tags: [String] {
|
||||
guard let data = metadata.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let tagsDict = json["tags"] as? [String: String]
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
return .init(tagsDict.values).sorted()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,22 +40,22 @@ struct AuthRequestFactoryTests {
|
|||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "client_id" }),
|
||||
queryItems.contains { $0.name == "client_id" },
|
||||
"It should include client_id parameter"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "scope" && $0.value == "everything" }),
|
||||
queryItems.contains { $0.name == "scope" && $0.value == "everything" },
|
||||
"It should include scope parameter with 'everything' value"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "response_type" && $0.value == "code" }),
|
||||
queryItems.contains { $0.name == "response_type" && $0.value == "code" },
|
||||
"It should include response_type parameter with 'code' value"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "redirect_uri" }),
|
||||
queryItems.contains { $0.name == "redirect_uri" },
|
||||
"It should include redirect_uri parameter"
|
||||
)
|
||||
}
|
||||
|
|
@ -82,27 +82,27 @@ struct AuthRequestFactoryTests {
|
|||
let queryItems = request.queryItems
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "client_id" }),
|
||||
queryItems.contains { $0.name == "client_id" },
|
||||
"It should include client_id query parameter"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "client_secret" }),
|
||||
queryItems.contains { $0.name == "client_secret" },
|
||||
"It should include client_secret query parameter"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "redirect_uri" }),
|
||||
queryItems.contains { $0.name == "redirect_uri" },
|
||||
"It should include redirect_uri query parameter"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "code" && $0.value == authCode }),
|
||||
queryItems.contains { $0.name == "code" && $0.value == authCode },
|
||||
"It should include code query parameter with provided auth code"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "scope" && $0.value == "everything" }),
|
||||
queryItems.contains { $0.name == "scope" && $0.value == "everything" },
|
||||
"It should include scope query parameter"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ struct PicsRequestFactoryTests {
|
|||
}
|
||||
|
||||
@Test("It should create picture edit request with empty tags array")
|
||||
func makeEditPictureRequest_withOnlyWithEmptyTagsArray_createsRequest() {
|
||||
func makeEditPictureRequest_withOnlyWithEmptyTagsArray_createsRequest() throws {
|
||||
// Given
|
||||
let address = "dave"
|
||||
let pictureID = "pic-456"
|
||||
|
|
@ -256,23 +256,26 @@ struct PicsRequestFactoryTests {
|
|||
"It should use PUT method"
|
||||
)
|
||||
|
||||
let body = try #require(request.body)
|
||||
let bodyTags = try #require(body.tags)
|
||||
|
||||
#expect(
|
||||
request.body?.tags == "",
|
||||
bodyTags.isEmpty == true,
|
||||
"It should include tags in request body"
|
||||
)
|
||||
|
||||
#expect(
|
||||
request.body?.caption == nil,
|
||||
body.caption == nil,
|
||||
"It should have nil caption when not provided"
|
||||
)
|
||||
|
||||
#expect(
|
||||
request.body?.alt == nil,
|
||||
body.alt == nil,
|
||||
"It should have nil alt text when not provided"
|
||||
)
|
||||
|
||||
#expect(
|
||||
request.body?.isHidden == nil,
|
||||
body.isHidden == nil,
|
||||
"It should have nil isHidden when not provided"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,12 +54,16 @@ struct WeblogRequestFactoryTests {
|
|||
// Given
|
||||
let address = "charlie"
|
||||
let content = "This is my first blog post!"
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
let date = Date(timeIntervalSince1970: 1_704_067_200) // 2024-01-01
|
||||
|
||||
// When
|
||||
let request = WeblogRequestFactory.makeCreateWeblogEntryRequest(
|
||||
address: address,
|
||||
content: content,
|
||||
status: status,
|
||||
tags: tags,
|
||||
date: date
|
||||
)
|
||||
|
||||
|
|
@ -86,6 +90,8 @@ struct WeblogRequestFactoryTests {
|
|||
let address = "dave"
|
||||
let entryID = "updated-post"
|
||||
let content = "This is an updated blog post!"
|
||||
let status = "Live"
|
||||
let tags: [String] = []
|
||||
let date = Date(timeIntervalSince1970: 1_704_153_600) // 2024-01-02
|
||||
|
||||
// When
|
||||
|
|
@ -93,6 +99,8 @@ struct WeblogRequestFactoryTests {
|
|||
address: address,
|
||||
entryID: entryID,
|
||||
content: content,
|
||||
status: status,
|
||||
tags: tags,
|
||||
date: date
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ struct PURLsEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: PURLsNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: PURLsPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -123,4 +124,5 @@ struct PURLsEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
|
||||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
func copy(
|
||||
_ string: String
|
||||
) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let purl = PURL(
|
||||
name: "purl\(i)",
|
||||
url: URL(string: "http://subdomain\(i).otavio.lol")!,
|
||||
name: "purl\(index)",
|
||||
url: URL(string: "http://subdomain\(index).otavio.lol")!,
|
||||
address: "otaviocc"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
func fetchPURLs(for address: String) {}
|
||||
func addPURL(address: String, name: String, url: String) {}
|
||||
func deletePURL(address: String, name: String) async throws {}
|
||||
func fetchPURLs(
|
||||
for address: String
|
||||
) {}
|
||||
|
||||
func addPURL(
|
||||
address: String,
|
||||
name: String,
|
||||
url: String
|
||||
) {}
|
||||
|
||||
func deletePURL(
|
||||
address: String,
|
||||
name: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -37,8 +37,17 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchPURLs() {}
|
||||
func addPURL(address: String, name: String, url: String) {}
|
||||
func deletePURL(address: String, name: String) async throws {}
|
||||
|
||||
func addPURL(
|
||||
address: String,
|
||||
name: String,
|
||||
url: String
|
||||
) {}
|
||||
|
||||
func deletePURL(
|
||||
address: String,
|
||||
name: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import XCTest
|
|||
|
||||
final class PURLsTests: XCTestCase {
|
||||
|
||||
// swiftlint:disable:next empty_xctest_method
|
||||
func testExample() throws {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ struct PastebinEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: PastebinNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: PastebinPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -123,4 +124,5 @@ struct PastebinEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let paste = Paste(
|
||||
title: "paste\(i).md",
|
||||
title: "paste\(index).md",
|
||||
content: "hello, world!",
|
||||
timestamp: 123_123_123,
|
||||
address: "otaviocc",
|
||||
listed: i % 2 == 0
|
||||
listed: index % 2 == 0
|
||||
)
|
||||
|
||||
container.mainContext.insert(
|
||||
|
|
|
|||
|
|
@ -17,9 +17,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
func fetchPastes(for address: String) async throws {}
|
||||
func createOrUpdatePaste(address: String, title: String, content: String, listed: Bool) async throws {}
|
||||
func deletePaste(address: String, title: String) async throws {}
|
||||
func fetchPastes(
|
||||
for address: String
|
||||
) async throws {}
|
||||
|
||||
func createOrUpdatePaste(
|
||||
address: String,
|
||||
title: String,
|
||||
content: String,
|
||||
listed: Bool
|
||||
) async throws {}
|
||||
|
||||
func deletePaste(
|
||||
address: String,
|
||||
title: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -37,8 +37,18 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchPastes() async throws {}
|
||||
func createOrUpdatePaste(address: String, title: String, content: String, isListed: Bool) async throws {}
|
||||
func deletePaste(address: String, title: String) async throws {}
|
||||
|
||||
func createOrUpdatePaste(
|
||||
address: String,
|
||||
title: String,
|
||||
content: String,
|
||||
isListed: Bool
|
||||
) async throws {}
|
||||
|
||||
func deletePaste(
|
||||
address: String,
|
||||
title: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import XCTest
|
|||
|
||||
final class PastebinTests: XCTestCase {
|
||||
|
||||
// swiftlint:disable:next empty_xctest_method
|
||||
func testExample() throws {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ struct PicsEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: PicsNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: PicsPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -119,4 +120,5 @@ struct PicsEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
|
||||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
func copy(
|
||||
_ string: String
|
||||
) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@
|
|||
|
||||
// MARK: - Public
|
||||
|
||||
func fetchPictures(for address: String) async throws -> [PictureResponse] { [] }
|
||||
func deletePicture(address: String, pictureID: String) async throws {}
|
||||
func fetchPictures(
|
||||
for address: String
|
||||
) async throws -> [PictureResponse] { [] }
|
||||
|
||||
func deletePicture(
|
||||
address: String,
|
||||
pictureID: String
|
||||
) async throws {}
|
||||
|
||||
func updatePicture(
|
||||
address: String,
|
||||
pictureID: String,
|
||||
|
|
@ -21,6 +28,7 @@
|
|||
alt: String,
|
||||
tags: [String]
|
||||
) async throws {}
|
||||
|
||||
func uploadPicture(
|
||||
address: String,
|
||||
data: Data,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,12 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchPictures() async throws {}
|
||||
func deletePicture(address: String, pictureID: String) async throws {}
|
||||
|
||||
func deletePicture(
|
||||
address: String,
|
||||
pictureID: String
|
||||
) async throws {}
|
||||
|
||||
func updatePicture(
|
||||
address: String,
|
||||
pictureID: String,
|
||||
|
|
@ -47,6 +52,7 @@
|
|||
alt: String,
|
||||
tags: [String]
|
||||
) async throws {}
|
||||
|
||||
func uploadPicture(
|
||||
address: String,
|
||||
data: Data,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@
|
|||
count: Int = 3,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
SomePicture.makePicture(
|
||||
created: Double(i * i),
|
||||
created: Double(index * index),
|
||||
in: container
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
altText: String? = nil,
|
||||
isHidden: Bool? = nil,
|
||||
tags: [String]? = nil,
|
||||
imageData: Data? = nil
|
||||
imageData: Data? = nil,
|
||||
isDragging: Bool? = nil
|
||||
) -> UploadViewModel {
|
||||
let viewModel = UploadViewModel(
|
||||
repository: PicsRepositoryMother.makePicsRepository(),
|
||||
|
|
@ -38,6 +39,10 @@
|
|||
tags.forEach { viewModel.addTag($0) }
|
||||
}
|
||||
|
||||
if let isDragging {
|
||||
viewModel.isDragging = isDragging
|
||||
}
|
||||
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ struct UploadPictureScene: Scene {
|
|||
UploadView(
|
||||
viewModel: viewModel
|
||||
)
|
||||
.frame(minWidth: 640, idealWidth: 640, maxWidth: 800)
|
||||
.environment(\.viewModelFactory, environment.viewModelFactory)
|
||||
.modelContainer(environment.modelContainer)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ struct EditPictureView: View {
|
|||
VStack {
|
||||
makeEditorView()
|
||||
}
|
||||
.frame(minWidth: 400, idealWidth: 640, maxWidth: 640)
|
||||
.toolbar {
|
||||
makeToolbarContent()
|
||||
}
|
||||
|
|
@ -44,17 +43,17 @@ struct EditPictureView: View {
|
|||
@ViewBuilder
|
||||
private func makeEditorView() -> some View {
|
||||
VStack {
|
||||
TextField("Caption", text: $viewModel.caption)
|
||||
.autocorrectionDisabled(false)
|
||||
.font(.body.monospaced())
|
||||
.textFieldCard()
|
||||
.help("Add a caption for your image")
|
||||
PlaceholderTextEditor(
|
||||
placeholder: "Caption",
|
||||
text: $viewModel.caption,
|
||||
help: "Add a caption for your image"
|
||||
)
|
||||
|
||||
TextEditor(text: $viewModel.altText)
|
||||
.autocorrectionDisabled(false)
|
||||
.font(.body.monospaced())
|
||||
.textEditorCard()
|
||||
.help("Add descriptive alt text for accessibility")
|
||||
PlaceholderTextEditor(
|
||||
placeholder: "Alt text",
|
||||
text: $viewModel.altText,
|
||||
help: "Add descriptive alt text for accessibility"
|
||||
)
|
||||
|
||||
makeTagsView()
|
||||
}
|
||||
|
|
@ -75,7 +74,7 @@ struct EditPictureView: View {
|
|||
.autocorrectionDisabled(true)
|
||||
.font(.body.monospaced())
|
||||
.textFieldCard()
|
||||
.help("Enter a tag and press the return key to add it")
|
||||
.help("Enter a tag and press the return key to add it, or press tab to select the first suggestion")
|
||||
.onSubmit {
|
||||
withAnimation {
|
||||
viewModel.addTag(viewModel.tagInput)
|
||||
|
|
@ -84,6 +83,14 @@ struct EditPictureView: View {
|
|||
.onChange(of: viewModel.tagInput) {
|
||||
viewModel.updateTagSuggestions(from: existingTags.map(\.title))
|
||||
}
|
||||
.onKeyPress(.tab) {
|
||||
do {
|
||||
try viewModel.selectFistTagSuggestion()
|
||||
return .handled
|
||||
} catch {
|
||||
return .ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -91,12 +98,13 @@ struct EditPictureView: View {
|
|||
if !viewModel.suggestedTags.isEmpty {
|
||||
TagListView(
|
||||
tags: viewModel.suggestedTags,
|
||||
helpText: { "Add existing tag '\($0)'" }
|
||||
) { tag in
|
||||
helpText: { "Add existing tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation {
|
||||
viewModel.addTag(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,12 +114,13 @@ struct EditPictureView: View {
|
|||
TagListView(
|
||||
tags: viewModel.tags,
|
||||
style: .remove,
|
||||
helpText: { "Remove tag '\($0)'" }
|
||||
) { tag in
|
||||
helpText: { "Remove tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation {
|
||||
viewModel.removeTag(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ import PicsRepository
|
|||
@Observable
|
||||
final class EditPictureViewModel {
|
||||
|
||||
// MARK: - Nested types
|
||||
|
||||
enum TagSelectionError: Error {
|
||||
|
||||
case noSuggestions
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let pictureID: String
|
||||
|
|
@ -108,4 +115,12 @@ final class EditPictureViewModel {
|
|||
func removeTag(_ tag: String) {
|
||||
tags.removeAll { $0 == tag }
|
||||
}
|
||||
|
||||
func selectFistTagSuggestion() throws(TagSelectionError) {
|
||||
guard let tag = suggestedTags.first else {
|
||||
throw .noSuggestions
|
||||
}
|
||||
|
||||
addTag(tag)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ struct PicturesListView: View {
|
|||
_pictures = .init(viewModel.fetchDescriptor())
|
||||
}
|
||||
|
||||
// MARK; - Public
|
||||
// MARK: - Public
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
|
|
|
|||
|
|
@ -25,17 +25,7 @@ struct UploadView: View {
|
|||
// MARK: - Public
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack(alignment: .top) {
|
||||
makeSidebarView()
|
||||
.frame(width: 200)
|
||||
|
||||
makeEditorView()
|
||||
}
|
||||
|
||||
makeVisibilityView()
|
||||
}
|
||||
.frame(minWidth: 400, idealWidth: 640, maxWidth: 640)
|
||||
makeContentView()
|
||||
.toolbar {
|
||||
makeToolbarContent()
|
||||
}
|
||||
|
|
@ -46,9 +36,6 @@ struct UploadView: View {
|
|||
}
|
||||
}
|
||||
.padding()
|
||||
.onDrop(of: [.image], isTargeted: $viewModel.isDragging) { providers -> Bool in
|
||||
handleDrop(providers: providers)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
|
@ -61,27 +48,64 @@ struct UploadView: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeSidebarView() -> some View {
|
||||
if viewModel.isDragging {
|
||||
makeDropPictureView()
|
||||
} else {
|
||||
private func makeContentView() -> some View {
|
||||
VStack {
|
||||
HStack(alignment: .top) {
|
||||
makeSidebarView()
|
||||
.frame(width: 200)
|
||||
makeEditorView()
|
||||
}
|
||||
makeVisibilityView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeSidebarView() -> some View {
|
||||
VStack {
|
||||
if viewModel.imageData != nil {
|
||||
makePictureView()
|
||||
makePicturePickerView()
|
||||
makeRemoveButtonView()
|
||||
} else {
|
||||
ZStack {
|
||||
makeDropZoneBorder()
|
||||
makeDropZoneContentView()
|
||||
}
|
||||
.onDrop(of: [.image], isTargeted: $viewModel.isDragging) { providers -> Bool in
|
||||
handleDrop(providers: providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeDropPictureView() -> some View {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: "arrow.down.heart")
|
||||
.font(.largeTitle)
|
||||
|
||||
Text("Drop it here")
|
||||
private func makeDropZoneBorder() -> some View {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
AnyShapeStyle(
|
||||
viewModel.isDragging ? Color.accentColor.opacity(0.3) : .secondary.opacity(0.05)
|
||||
)
|
||||
)
|
||||
.stroke(Color.accentColor, lineWidth: 1.0)
|
||||
.opacity(0.3)
|
||||
.frame(minHeight: 200)
|
||||
}
|
||||
.frame(width: 100, height: 100)
|
||||
.card(.accentColor)
|
||||
|
||||
@ViewBuilder
|
||||
private func makeDropZoneContentView() -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: viewModel.dropZoneImageName)
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(viewModel.isDragging ? Color.accentColor : .secondary)
|
||||
|
||||
if !viewModel.isDragging {
|
||||
Text("Drag your picture here or click the button to select one from your Photo Library")
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
makePicturePickerView()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -102,7 +126,7 @@ struct UploadView: View {
|
|||
matching: .images,
|
||||
photoLibrary: .shared()
|
||||
) {
|
||||
Text("Select Picture")
|
||||
Text("Select from Library")
|
||||
}
|
||||
.help("Choose an image from your photo library")
|
||||
.onChange(of: selectedItem) {
|
||||
|
|
@ -113,20 +137,34 @@ struct UploadView: View {
|
|||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeRemoveButtonView() -> some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.imageData = nil
|
||||
selectedItem = nil
|
||||
}
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
.help("Remove selected image")
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeEditorView() -> some View {
|
||||
VStack {
|
||||
TextField("Caption", text: $viewModel.caption)
|
||||
.autocorrectionDisabled(false)
|
||||
.font(.body.monospaced())
|
||||
.textFieldCard()
|
||||
.help("Add a caption for your image")
|
||||
PlaceholderTextEditor(
|
||||
placeholder: "Caption",
|
||||
text: $viewModel.caption,
|
||||
help: "Add a caption for your image"
|
||||
)
|
||||
|
||||
TextEditor(text: $viewModel.altText)
|
||||
.autocorrectionDisabled(false)
|
||||
.font(.body.monospaced())
|
||||
.textEditorCard()
|
||||
.help("Add descriptive alt text for accessibility")
|
||||
PlaceholderTextEditor(
|
||||
placeholder: "Alt text",
|
||||
text: $viewModel.altText,
|
||||
help: "Add descriptive alt text for accessibility"
|
||||
)
|
||||
|
||||
makeTagsView()
|
||||
}
|
||||
|
|
@ -147,15 +185,23 @@ struct UploadView: View {
|
|||
.autocorrectionDisabled(true)
|
||||
.font(.body.monospaced())
|
||||
.textFieldCard()
|
||||
.help("Enter a tag and press the return key to add it")
|
||||
.help("Enter a tag and press the return key to add it, or press tab to select the first suggestion")
|
||||
.onSubmit {
|
||||
withAnimation {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.addTag(viewModel.tagInput)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.tagInput) {
|
||||
viewModel.updateTagSuggestions(from: existingTags.map(\.title))
|
||||
}
|
||||
.onKeyPress(.tab) {
|
||||
do {
|
||||
try viewModel.selectFistTagSuggestion()
|
||||
return .handled
|
||||
} catch {
|
||||
return .ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -163,12 +209,13 @@ struct UploadView: View {
|
|||
if !viewModel.suggestedTags.isEmpty {
|
||||
TagListView(
|
||||
tags: viewModel.suggestedTags,
|
||||
helpText: { "Add existing tag '\($0)'" }
|
||||
) { tag in
|
||||
withAnimation {
|
||||
helpText: { "Add existing tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.addTag(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -178,12 +225,13 @@ struct UploadView: View {
|
|||
TagListView(
|
||||
tags: viewModel.tags,
|
||||
style: .remove,
|
||||
helpText: { "Remove tag '\($0)'" }
|
||||
) { tag in
|
||||
withAnimation {
|
||||
helpText: { "Remove tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.removeTag(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -247,4 +295,12 @@ struct UploadView: View {
|
|||
)
|
||||
}
|
||||
|
||||
#Preview("Drag-And-Drop") {
|
||||
UploadView(
|
||||
viewModel: UploadViewModelMother.makeUploadViewModel(
|
||||
isDragging: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ import UniformTypeIdentifiers
|
|||
@Observable
|
||||
final class UploadViewModel {
|
||||
|
||||
// MARK: - Nested types
|
||||
|
||||
enum TagSelectionError: Error {
|
||||
|
||||
case noSuggestions
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var caption = ""
|
||||
|
|
@ -29,8 +36,6 @@ final class UploadViewModel {
|
|||
|
||||
@ObservationIgnored private var observationTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var isSubmitDisabled: Bool {
|
||||
let trimmedCaption = caption.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedCaption.isEmpty || isSubmitting || imageData == nil
|
||||
|
|
@ -44,6 +49,10 @@ final class UploadViewModel {
|
|||
isSubmitting
|
||||
}
|
||||
|
||||
var dropZoneImageName: String {
|
||||
isDragging ? "photo.badge.plus.fill" : "photo.badge.plus"
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
|
|
@ -143,6 +152,14 @@ final class UploadViewModel {
|
|||
tags.removeAll { $0 == tag }
|
||||
}
|
||||
|
||||
func selectFistTagSuggestion() throws(TagSelectionError) {
|
||||
guard let tag = suggestedTags.first else {
|
||||
throw .noSuggestions
|
||||
}
|
||||
|
||||
addTag(tag)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setUpObservers() {
|
||||
|
|
|
|||
|
|
@ -30,17 +30,27 @@ public struct EditWeblogEntry: Codable, Hashable {
|
|||
/// The unique identifier for the weblog entry, if it already exists.
|
||||
public let entryID: String?
|
||||
|
||||
/// The publication status of the weblog entry.
|
||||
public let status: String?
|
||||
|
||||
/// An array of tags associated with the weblog entry.
|
||||
public let tags: [String]
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public init(
|
||||
address: String,
|
||||
body: String,
|
||||
date: Date,
|
||||
entryID: String?
|
||||
entryID: String?,
|
||||
status: String?,
|
||||
tags: [String]
|
||||
) {
|
||||
self.address = address
|
||||
self.body = body
|
||||
self.date = date
|
||||
self.entryID = entryID
|
||||
self.status = status
|
||||
self.tags = tags
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ struct StatusEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
repositoryFactory: StatusRepositoryFactoryProtocol,
|
||||
networkServiceFactory: StatusNetworkServiceFactoryProtocol,
|
||||
|
|
@ -120,4 +121,5 @@ struct StatusEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
|
||||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
func copy(
|
||||
_ string: String
|
||||
) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let status = Status(
|
||||
username: "user\(i)",
|
||||
statusID: "(i)",
|
||||
timestamp: Double(i),
|
||||
username: "user\(index)",
|
||||
statusID: "(index)",
|
||||
timestamp: Double(index),
|
||||
icon: "🤣",
|
||||
content: "Nulla purus urna, bibendum nec purus."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,9 +45,10 @@ public struct StatusApp: View {
|
|||
ToolbarItemGroup {
|
||||
SelectionToolbarItem(
|
||||
options: StatusListFilter.allCases,
|
||||
selection: $filter,
|
||||
itemLabel: { $0.localizedTitle }
|
||||
)
|
||||
selection: $filter
|
||||
) { label in
|
||||
label.localizedTitle
|
||||
}
|
||||
.selectionToolbarItemStyle(FilterSelectionToolbarItemStyle())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,13 @@ struct ClipboardService: ClipboardServiceProtocol {
|
|||
service.copy(string)
|
||||
}
|
||||
|
||||
func copy(_ data: Data, type: String) {
|
||||
service.copy(data, type: type)
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {
|
||||
service.copy(
|
||||
data,
|
||||
type: type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@
|
|||
)
|
||||
}
|
||||
|
||||
func copy(_ data: Data, type: String) {
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {
|
||||
NSPasteboard
|
||||
.general
|
||||
.clearContents()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ struct WeblogEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: WeblogNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: WeblogPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -119,4 +120,5 @@ struct WeblogEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ final class ViewModelFactory: Sendable {
|
|||
timestamp: entry.date,
|
||||
address: entry.address,
|
||||
location: entry.location,
|
||||
tags: entry.tags ?? [],
|
||||
repository: container.resolve(),
|
||||
clipboardService: container.resolve()
|
||||
)
|
||||
|
|
@ -63,13 +64,17 @@ final class ViewModelFactory: Sendable {
|
|||
address: String,
|
||||
body: String,
|
||||
date: Date,
|
||||
entryID: String?
|
||||
entryID: String?,
|
||||
status: WeblogEntryStatus,
|
||||
tags: [String]
|
||||
) -> EditorViewModel {
|
||||
.init(
|
||||
address: address,
|
||||
body: body,
|
||||
date: date,
|
||||
entryID: entryID,
|
||||
status: status,
|
||||
tags: tags,
|
||||
repository: container.resolve()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
|
||||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
func copy(
|
||||
_ string: String
|
||||
) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -7,12 +7,17 @@
|
|||
// MARK: - Public
|
||||
|
||||
@MainActor
|
||||
static func makeEditorViewModel() -> EditorViewModel {
|
||||
static func makeEditorViewModel(
|
||||
status: WeblogEntryStatus = .draft,
|
||||
tags: [String] = []
|
||||
) -> EditorViewModel {
|
||||
.init(
|
||||
address: "alice",
|
||||
body: "# This is the title\n\nThis is the body...",
|
||||
date: .init(),
|
||||
entryID: nil,
|
||||
status: status,
|
||||
tags: tags,
|
||||
repository: WeblogRepositoryMother.makeWeblogRepository()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,14 +27,14 @@
|
|||
}
|
||||
|
||||
static func makeEntryResponses(count: Int = 5) -> [EntryResponse] {
|
||||
(0..<count).map { i in
|
||||
(0..<count).map { index in
|
||||
makeEntryResponse(
|
||||
id: "entry-\(i)",
|
||||
location: "test-entry-\(i)",
|
||||
date: Double(1_700_000_000 + (i * 86400)),
|
||||
status: i % 3 == 0 ? "draft" : "published",
|
||||
title: "Test Entry \(i)",
|
||||
body: "Content for test entry \(i)"
|
||||
id: "entry-\(index)",
|
||||
location: "test-entry-\(index)",
|
||||
date: Double(1_700_000_000 + (index * 86400)),
|
||||
status: index % 3 == 0 ? "draft" : "published",
|
||||
title: "Test Entry \(index)",
|
||||
body: "Content for test entry \(index)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let entry = WeblogEntry(
|
||||
id: "entry-\(i)",
|
||||
title: "Test Entry \(i)",
|
||||
body: "This is the body for test entry \(i). Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
date: Double(1_700_000_000 + (i * 86400)),
|
||||
status: i % 3 == 0 ? "draft" : "published",
|
||||
location: "test-entry-\(i)",
|
||||
id: "entry-\(index)",
|
||||
title: "Test Entry \(index)",
|
||||
body: "This is the body for test entry \(index). Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
date: Double(1_700_000_000 + (index * 86400)),
|
||||
status: index % 3 == 0 ? "draft" : "published",
|
||||
location: "test-entry-\(index)",
|
||||
address: "otaviocc"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@
|
|||
id: "blogpostID",
|
||||
title: "This is a test",
|
||||
body: "This is just a test post. ",
|
||||
status: isDraft ? "draft" : "published",
|
||||
status: isDraft ? "draft" : "live",
|
||||
timestamp: 12_312_312,
|
||||
address: "otaviocc",
|
||||
location: "/2022/12/my-weblog-post",
|
||||
tags: .init(),
|
||||
repository: WeblogRepositoryMother.makeWeblogRepository(),
|
||||
clipboardService: ClipboardServiceMother.makeClipboardService()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,15 +12,26 @@
|
|||
|
||||
// MARK: - Public
|
||||
|
||||
func fetchWeblogEntry(for address: String, entryID: String) async throws -> EntryResponse {
|
||||
func fetchWeblogEntry(
|
||||
for address: String,
|
||||
entryID: String
|
||||
) async throws -> EntryResponse {
|
||||
EntryResponseMother.makeEntryResponse()
|
||||
}
|
||||
|
||||
func fetchWeblogEntries(for address: String) async throws -> [EntryResponse] {
|
||||
func fetchWeblogEntries(
|
||||
for address: String
|
||||
) async throws -> [EntryResponse] {
|
||||
EntryResponseMother.makeEntryResponses(count: 2)
|
||||
}
|
||||
|
||||
func createWeblogEntry(address: String, content: String, date: Date) async throws -> EntryResponse {
|
||||
func createWeblogEntry(
|
||||
address: String,
|
||||
content: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) async throws -> EntryResponse {
|
||||
EntryResponseMother.makeEntryResponse()
|
||||
}
|
||||
|
||||
|
|
@ -28,12 +39,17 @@
|
|||
address: String,
|
||||
entryID: String,
|
||||
content: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) async throws -> EntryResponse {
|
||||
EntryResponseMother.makeEntryResponse()
|
||||
}
|
||||
|
||||
func deleteWeblogEntry(address: String, entryID: String) async throws {}
|
||||
func deleteWeblogEntry(
|
||||
address: String,
|
||||
entryID: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -39,8 +39,20 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchEntries() async throws {}
|
||||
func createOrUpdateEntry(address: String, entryID: String?, body: String, date: Date) async throws {}
|
||||
func deleteEntry(address: String, entryID: String) async throws {}
|
||||
|
||||
func deleteEntry(
|
||||
address: String,
|
||||
entryID: String
|
||||
) async throws {}
|
||||
|
||||
func createOrUpdateEntry(
|
||||
address: String,
|
||||
entryID: String?,
|
||||
body: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import Foundation
|
||||
|
||||
/// Represents the publication status of a weblog entry.
|
||||
///
|
||||
/// Status values control how entries are displayed and distributed across
|
||||
/// the weblog and its feeds. Each status provides different visibility
|
||||
/// and accessibility options for entries.
|
||||
enum WeblogEntryStatus: String, CaseIterable, Hashable, Identifiable {
|
||||
|
||||
/// Draft status marks an entry as unpublished.
|
||||
///
|
||||
/// Entries with this status are not publicly accessible and are
|
||||
/// typically used for work-in-progress content.
|
||||
case draft = "Draft"
|
||||
|
||||
/// Live status makes an entry fully published and accessible.
|
||||
///
|
||||
/// Entries with this status appear on the weblog, in post lists,
|
||||
/// on tag pages, and are included in RSS, Atom, and JSON feeds.
|
||||
case live
|
||||
|
||||
/// Feed Only status hides the entry from the web but includes it in feeds.
|
||||
///
|
||||
/// Entries with this status are treated as unlisted on the web (not
|
||||
/// appearing in post lists, tag pages, or as landing pages) but are
|
||||
/// included in RSS, Atom, and JSON feeds.
|
||||
case liveRSSOnly = "Feed Only"
|
||||
|
||||
/// Web Only status hides the entry from feeds but keeps it on the web.
|
||||
///
|
||||
/// Entries with this status are accessible on the weblog and appear
|
||||
/// in post lists and tag pages, but are completely hidden from RSS,
|
||||
/// Atom, and JSON feeds.
|
||||
case liveWebOnly = "Web Only"
|
||||
|
||||
/// Unlisted status makes an entry accessible only via direct URL.
|
||||
///
|
||||
/// Entries with this status are not served as default/landing pages,
|
||||
/// do not appear in post lists or recent posts, and are not included
|
||||
/// on tag pages. They are only accessible to people who know the
|
||||
/// entry's direct URL.
|
||||
case unlisted = "Unlisted"
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var id: Self { self }
|
||||
var displayName: String { rawValue.capitalized }
|
||||
}
|
||||
|
|
@ -24,9 +24,11 @@ struct EditWeblogEntryScene: Scene {
|
|||
for: EditWeblogEntry.self
|
||||
) { $entry in
|
||||
makeEditorView(
|
||||
body: entry?.body ?? "# Title of your post\n\nThis is the body of your post...",
|
||||
date: entry?.date ?? Date(),
|
||||
body: entry?.body ?? "",
|
||||
date: entry?.date ?? .init(),
|
||||
entryID: entry?.entryID,
|
||||
status: entry?.status.flatMap(WeblogEntryStatus.init) ?? .draft,
|
||||
tags: entry?.tags ?? .init(),
|
||||
address: entry?.address ?? ""
|
||||
)
|
||||
}
|
||||
|
|
@ -44,6 +46,8 @@ struct EditWeblogEntryScene: Scene {
|
|||
body: String,
|
||||
date: Date,
|
||||
entryID: String?,
|
||||
status: WeblogEntryStatus,
|
||||
tags: [String],
|
||||
address: String
|
||||
) -> some View {
|
||||
let viewModel = environment.viewModelFactory
|
||||
|
|
@ -51,12 +55,15 @@ struct EditWeblogEntryScene: Scene {
|
|||
address: address,
|
||||
body: body,
|
||||
date: date,
|
||||
entryID: entryID
|
||||
entryID: entryID,
|
||||
status: status,
|
||||
tags: tags
|
||||
)
|
||||
|
||||
EditorView(
|
||||
viewModel: viewModel
|
||||
)
|
||||
.frame(minWidth: 420, idealWidth: 420, maxWidth: 640)
|
||||
.modelContainer(environment.modelContainer)
|
||||
.frame(minWidth: 640, idealWidth: 640, maxWidth: 800)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,9 +78,21 @@ struct WeblogApp: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeAddEntryToolbarItem() -> some View {
|
||||
private func makeAddEntryToolbarItem(
|
||||
address: SelectedAddress
|
||||
) -> some View {
|
||||
Button {
|
||||
openWindow(id: EditWeblogEntryWindow.id)
|
||||
openWindow(
|
||||
id: EditWeblogEntryWindow.id,
|
||||
value: EditWeblogEntry(
|
||||
address: address,
|
||||
body: "# Title of your post\n\nThis is the body of your post...",
|
||||
date: .init(),
|
||||
entryID: nil,
|
||||
status: nil,
|
||||
tags: .init()
|
||||
)
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
|
|
@ -108,7 +120,7 @@ struct WeblogApp: View {
|
|||
helpText: "Refresh weblog entries",
|
||||
isDisabled: viewModel.isLoading
|
||||
)
|
||||
makeAddEntryToolbarItem()
|
||||
makeAddEntryToolbarItem(address: address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import DesignSystem
|
||||
import FoundationExtensions
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
import WeblogPersistenceService
|
||||
|
||||
struct EditorView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@State private var viewModel: EditorViewModel
|
||||
@State private var isPopoverPresented = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query(WeblogTag.fetchDescriptor()) private var existingTags: [WeblogTag]
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
|
|
@ -20,10 +23,13 @@ struct EditorView: View {
|
|||
// MARK: - Public
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top) {
|
||||
makeComposeView()
|
||||
makeSidebarView()
|
||||
}
|
||||
makeSelectedTagsView()
|
||||
}
|
||||
.toolbar {
|
||||
makeToolbarContent()
|
||||
}
|
||||
|
|
@ -48,8 +54,42 @@ struct EditorView: View {
|
|||
|
||||
@ViewBuilder
|
||||
private func makeSidebarView() -> some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
makeDatePicker()
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
makeTimePicker()
|
||||
}
|
||||
|
||||
Divider()
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
makeStatusPicker()
|
||||
}
|
||||
|
||||
Divider()
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Text("Tags")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
VStack {
|
||||
makeTagInputView()
|
||||
makeTagSuggestionsView()
|
||||
makeTagInputDescription()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 200)
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeDatePicker() -> some View {
|
||||
Text("Date")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
|
|
@ -60,7 +100,8 @@ struct EditorView: View {
|
|||
.help("Select publication date")
|
||||
}
|
||||
|
||||
GridRow {
|
||||
@ViewBuilder
|
||||
private func makeTimePicker() -> some View {
|
||||
Text("Time")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
|
|
@ -70,8 +111,89 @@ struct EditorView: View {
|
|||
) {}
|
||||
.help("Select publication time")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeStatusPicker() -> some View {
|
||||
Text("Status")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
Picker(
|
||||
selection: $viewModel.status,
|
||||
content: {
|
||||
ForEach(WeblogEntryStatus.allCases) { status in
|
||||
Text(status.displayName)
|
||||
}
|
||||
},
|
||||
label: { EmptyView() }
|
||||
)
|
||||
.pickerStyle(.radioGroup)
|
||||
.labelsHidden()
|
||||
.help("Select publication visibility")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeTagInputView() -> some View {
|
||||
TextField("Add tag", text: $viewModel.tagInput)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(.body.monospaced())
|
||||
.textFieldCard()
|
||||
.help("Enter a tag and press the return key to add it, or press tab to select the first suggestion")
|
||||
.onSubmit {
|
||||
withAnimation {
|
||||
viewModel.addTag(viewModel.tagInput)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.tagInput) {
|
||||
viewModel.updateTagSuggestions(from: existingTags.map(\.title))
|
||||
}
|
||||
.onKeyPress(.tab) {
|
||||
do {
|
||||
try viewModel.selectFistTagSuggestion()
|
||||
return .handled
|
||||
} catch {
|
||||
return .ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeTagSuggestionsView() -> some View {
|
||||
if !viewModel.suggestedTags.isEmpty {
|
||||
TagListView(
|
||||
tags: viewModel.suggestedTags,
|
||||
helpText: { "Add existing tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation {
|
||||
viewModel.addTag(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeTagInputDescription() -> some View {
|
||||
if viewModel.suggestedTags.isEmpty {
|
||||
Text("Type a tag and press Return")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeSelectedTagsView() -> some View {
|
||||
if !viewModel.tags.isEmpty {
|
||||
TagListView(
|
||||
tags: viewModel.tags,
|
||||
style: .remove,
|
||||
helpText: { "Remove tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation {
|
||||
viewModel.removeTag(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -100,10 +222,19 @@ struct EditorView: View {
|
|||
|
||||
#if DEBUG
|
||||
|
||||
#Preview(traits: .sizeThatFitsLayout) {
|
||||
#Preview("Draft", traits: .sizeThatFitsLayout) {
|
||||
EditorView(
|
||||
viewModel: EditorViewModelMother.makeEditorViewModel()
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Live with Tags", traits: .sizeThatFitsLayout) {
|
||||
EditorView(
|
||||
viewModel: EditorViewModelMother.makeEditorViewModel(
|
||||
status: .live,
|
||||
tags: ["Foo", "Bar"]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Foundation
|
||||
import FoundationExtensions
|
||||
import Observation
|
||||
import WeblogRepository
|
||||
|
||||
|
|
@ -6,12 +7,23 @@ import WeblogRepository
|
|||
@Observable
|
||||
final class EditorViewModel {
|
||||
|
||||
// MARK: - Nested types
|
||||
|
||||
enum TagSelectionError: Error {
|
||||
|
||||
case noSuggestions
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var body: String
|
||||
var entryID: String?
|
||||
var date: Date
|
||||
var status: WeblogEntryStatus
|
||||
var tagInput = ""
|
||||
var shouldDismiss = false
|
||||
private(set) var tags: [String] = []
|
||||
private(set) var suggestedTags: [String] = []
|
||||
private(set) var isSubmitting = false
|
||||
|
||||
private let address: String
|
||||
|
|
@ -38,12 +50,16 @@ final class EditorViewModel {
|
|||
body: String,
|
||||
date: Date,
|
||||
entryID: String?,
|
||||
status: WeblogEntryStatus,
|
||||
tags: [String],
|
||||
repository: any WeblogRepositoryProtocol
|
||||
) {
|
||||
self.address = address
|
||||
self.body = body
|
||||
self.date = date
|
||||
self.entryID = entryID
|
||||
self.status = status
|
||||
self.tags = tags
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +76,8 @@ final class EditorViewModel {
|
|||
address: address,
|
||||
entryID: entryID,
|
||||
body: body,
|
||||
status: status.rawValue,
|
||||
tags: tags,
|
||||
date: date
|
||||
)
|
||||
shouldDismiss = true
|
||||
|
|
@ -68,4 +86,48 @@ final class EditorViewModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateTagSuggestions(
|
||||
from existingTags: [String]
|
||||
) {
|
||||
let trimmedInput = tagInput
|
||||
.slugified()
|
||||
.lowercased()
|
||||
|
||||
guard !trimmedInput.isEmpty else {
|
||||
suggestedTags = []
|
||||
return
|
||||
}
|
||||
|
||||
suggestedTags = existingTags
|
||||
.filter { tag in
|
||||
tag.lowercased().contains(trimmedInput) && !tags.contains(tag)
|
||||
}
|
||||
.prefix(5)
|
||||
.map(\.self)
|
||||
}
|
||||
|
||||
func addTag(_ tag: String) {
|
||||
let trimmedTag = tag.slugified()
|
||||
|
||||
guard !trimmedTag.isEmpty, !tags.contains(trimmedTag) else {
|
||||
tagInput = ""
|
||||
return
|
||||
}
|
||||
|
||||
tags.append(trimmedTag)
|
||||
tagInput = ""
|
||||
}
|
||||
|
||||
func removeTag(_ tag: String) {
|
||||
tags.removeAll { $0 == tag }
|
||||
}
|
||||
|
||||
func selectFistTagSuggestion() throws(TagSelectionError) {
|
||||
guard let tag = suggestedTags.first else {
|
||||
throw .noSuggestions
|
||||
}
|
||||
|
||||
addTag(tag)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ struct WeblogEntriesListView: View {
|
|||
_entries = .init(viewModel.fetchDescriptor())
|
||||
}
|
||||
|
||||
// MARK; - Public
|
||||
// MARK: - Public
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ struct WeblogEntryView: View {
|
|||
VStack(alignment: .leading, spacing: 4) {
|
||||
makeHeaderView()
|
||||
makeContentView()
|
||||
|
||||
if viewModel.showStatus {
|
||||
makeStatusView()
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
|
|
@ -65,16 +69,34 @@ struct WeblogEntryView: View {
|
|||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeStatusView() -> some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(viewModel.status)
|
||||
.textCase(.uppercase)
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeContextualMenu() -> some View {
|
||||
makeEditEntryMenuItem()
|
||||
|
||||
if !viewModel.isDraft {
|
||||
Divider()
|
||||
makeCopyEntryURLMenuItem()
|
||||
makeCopyMarkdownLinkMenuItem()
|
||||
}
|
||||
|
||||
if !viewModel.isDraft {
|
||||
Divider()
|
||||
makeOpenInBrowserMenuItem()
|
||||
makeShareMenuItem()
|
||||
makeShareOnStatuslogMenuItem()
|
||||
}
|
||||
|
||||
Divider()
|
||||
makeDeleteEntryMenuItem()
|
||||
}
|
||||
|
|
@ -84,7 +106,7 @@ struct WeblogEntryView: View {
|
|||
Button {
|
||||
openEditor()
|
||||
} label: {
|
||||
Label("Edit Entry", systemImage: "link")
|
||||
Label("Edit Entry", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +178,9 @@ struct WeblogEntryView: View {
|
|||
address: viewModel.address,
|
||||
body: viewModel.body,
|
||||
date: viewModel.publishedDate,
|
||||
entryID: viewModel.id
|
||||
entryID: viewModel.id,
|
||||
status: viewModel.status,
|
||||
tags: viewModel.tags
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ final class WeblogEntryViewModel: Identifiable {
|
|||
let timestamp: Double
|
||||
let address: String
|
||||
let location: String
|
||||
let tags: [String]
|
||||
|
||||
var publishedDate: Date {
|
||||
Date(timeIntervalSince1970: timestamp)
|
||||
|
|
@ -34,8 +35,12 @@ final class WeblogEntryViewModel: Identifiable {
|
|||
)
|
||||
}
|
||||
|
||||
var isLive: Bool {
|
||||
status == "live"
|
||||
var showStatus: Bool {
|
||||
status.lowercased() != "live"
|
||||
}
|
||||
|
||||
var isDraft: Bool {
|
||||
status.lowercased() == "draft"
|
||||
}
|
||||
|
||||
private let repository: any WeblogRepositoryProtocol
|
||||
|
|
@ -51,6 +56,7 @@ final class WeblogEntryViewModel: Identifiable {
|
|||
timestamp: Double,
|
||||
address: String,
|
||||
location: String,
|
||||
tags: [String],
|
||||
repository: any WeblogRepositoryProtocol,
|
||||
clipboardService: ClipboardServiceProtocol
|
||||
) {
|
||||
|
|
@ -61,6 +67,7 @@ final class WeblogEntryViewModel: Identifiable {
|
|||
self.timestamp = timestamp
|
||||
self.address = address
|
||||
self.location = location
|
||||
self.tags = tags
|
||||
self.repository = repository
|
||||
self.clipboardService = clipboardService
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ public struct EntryResponse: Identifiable, Equatable, Sendable {
|
|||
/// multi-address support within the same application instance.
|
||||
public let address: String
|
||||
|
||||
/// An array of tags associated with the weblog entry for categorization and discovery.
|
||||
///
|
||||
/// These user-defined tags enable content organization, filtering, and search
|
||||
/// functionality. Tags can be used to group related entries or identify
|
||||
/// specific themes, events, or characteristics of the content. The array may
|
||||
/// be empty if no tags have been assigned to the entry.
|
||||
public let tags: [String]
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
public init(
|
||||
|
|
@ -61,7 +69,8 @@ public struct EntryResponse: Identifiable, Equatable, Sendable {
|
|||
status: String,
|
||||
title: String,
|
||||
body: String,
|
||||
address: String
|
||||
address: String,
|
||||
tags: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.location = location
|
||||
|
|
@ -70,5 +79,6 @@ public struct EntryResponse: Identifiable, Equatable, Sendable {
|
|||
self.title = title
|
||||
self.body = body
|
||||
self.address = address
|
||||
self.tags = tags
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,22 +41,26 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable {
|
|||
/// Creates a new weblog entry for the specified address.
|
||||
///
|
||||
/// This method sends a POST request to the OMG.LOL API to create a new weblog entry.
|
||||
/// The content is formatted with frontmatter including the publication date and
|
||||
/// sent as raw data to the API endpoint.
|
||||
/// The content is formatted with frontmatter including the publication date, status,
|
||||
/// and sent as raw data to the API endpoint.
|
||||
///
|
||||
/// The request requires authentication and will create a new entry with a
|
||||
/// system-generated unique identifier. The entry will be immediately available
|
||||
/// at the user's weblog URL.
|
||||
/// at the user's weblog URL according to the specified status.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - address: The user address (username) to create the entry for
|
||||
/// - content: The markdown content body of the weblog entry
|
||||
/// - status: The publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only", "Unlisted")
|
||||
/// - tags: An array of tags associated with the entry
|
||||
/// - date: The publication date for the entry
|
||||
/// - Returns: The created weblog entry with metadata and generated ID
|
||||
/// - Throws: Network errors, authentication errors, or API-specific errors.
|
||||
func createWeblogEntry(
|
||||
address: String,
|
||||
content: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) async throws -> EntryResponse
|
||||
|
||||
|
|
@ -64,16 +68,19 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable {
|
|||
///
|
||||
/// This method sends a POST request to the OMG.LOL API to update an existing
|
||||
/// weblog entry identified by its entry ID. The content is formatted with
|
||||
/// frontmatter including the updated publication date and sent as raw data.
|
||||
/// frontmatter including the updated publication date, status, and sent as raw data.
|
||||
///
|
||||
/// The request requires authentication and the user must own the entry being
|
||||
/// updated. The entry's URL and slug will remain unchanged, but the content,
|
||||
/// publication date, and metadata will be updated.
|
||||
/// publication date, status, and metadata will be updated.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - address: The user address (username) who owns the entry
|
||||
/// - entryID: The unique identifier of the entry to update
|
||||
/// - content: The updated markdown content body of the weblog entry
|
||||
/// - status: The updated publication status of the entry (e.g., "Draft", "Live", "Feed Only", "Web Only",
|
||||
/// "Unlisted")
|
||||
/// - tags: An array of tags associated with the entry
|
||||
/// - date: The updated publication date for the entry
|
||||
/// - Returns: The updated weblog entry with new content and metadata
|
||||
/// - Throws: Network errors, authentication errors, or API-specific errors if the entry is not found.
|
||||
|
|
@ -81,6 +88,8 @@ public protocol WeblogNetworkServiceProtocol: AnyObject, Sendable {
|
|||
address: String,
|
||||
entryID: String,
|
||||
content: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) async throws -> EntryResponse
|
||||
|
||||
|
|
@ -143,12 +152,16 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol {
|
|||
func createWeblogEntry(
|
||||
address: String,
|
||||
content: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) async throws -> EntryResponse {
|
||||
let response = try await networkClient.run(
|
||||
WeblogRequestFactory.makeCreateWeblogEntryRequest(
|
||||
address: address,
|
||||
content: content,
|
||||
status: status,
|
||||
tags: tags,
|
||||
date: date
|
||||
)
|
||||
)
|
||||
|
|
@ -163,6 +176,8 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol {
|
|||
address: String,
|
||||
entryID: String,
|
||||
content: String,
|
||||
status: String,
|
||||
tags: [String],
|
||||
date: Date
|
||||
) async throws -> EntryResponse {
|
||||
let response = try await networkClient.run(
|
||||
|
|
@ -170,6 +185,8 @@ actor WeblogNetworkService: WeblogNetworkServiceProtocol {
|
|||
address: address,
|
||||
entryID: entryID,
|
||||
content: content,
|
||||
status: status,
|
||||
tags: tags,
|
||||
date: date
|
||||
)
|
||||
)
|
||||
|
|
@ -211,6 +228,7 @@ private extension EntryResponse {
|
|||
title = weblogEntryResponse.title
|
||||
body = weblogEntryResponse.body
|
||||
address = weblogEntryResponse.address
|
||||
tags = weblogEntryResponse.tags
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +237,8 @@ private extension EntryResponse {
|
|||
/// Initializes the `CreateWeblogEntryResponse` model from the network response
|
||||
/// model, so that the client doesn't depend on network models.
|
||||
///
|
||||
/// Tags not returned in create/update response, will be synced on next fetch.
|
||||
///
|
||||
/// - Parameter createWeblogEntryResponse: The network model to be mapped.
|
||||
init(
|
||||
address: String,
|
||||
|
|
@ -230,6 +250,7 @@ private extension EntryResponse {
|
|||
status = createWeblogEntryResponse.status
|
||||
title = createWeblogEntryResponse.title
|
||||
body = createWeblogEntryResponse.body
|
||||
tags = .init()
|
||||
self.address = address
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
public extension WeblogTag {
|
||||
|
||||
/// Creates a fetch descriptor for retrieving tags sorted alphabetically by title.
|
||||
///
|
||||
/// This method provides a pre-configured fetch descriptor that sorts tags
|
||||
/// by title in ascending order (A-Z). This is the standard sorting order
|
||||
/// for displaying tag collections, presenting them in a predictable alphabetical
|
||||
/// sequence for easy browsing and selection.
|
||||
///
|
||||
/// - Returns: A `FetchDescriptor<WeblogTag>` configured for alphabetical title sorting.
|
||||
static func fetchDescriptor() -> FetchDescriptor<WeblogTag> {
|
||||
.init(
|
||||
sortBy: [.init(\.title)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@ extension WeblogEntry {
|
|||
date: storableEntry.date,
|
||||
status: storableEntry.status,
|
||||
location: storableEntry.location,
|
||||
address: storableEntry.address
|
||||
address: storableEntry.address,
|
||||
tags: storableEntry.tags.isEmpty ? nil : storableEntry.tags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,13 +64,13 @@ public struct WeblogPersistenceServiceFactory: WeblogPersistenceServiceFactoryPr
|
|||
) -> any WeblogPersistenceServiceProtocol {
|
||||
let configuration = ModelConfiguration(
|
||||
"entries",
|
||||
schema: .init([WeblogEntry.self]),
|
||||
schema: .init([WeblogEntry.self, WeblogTag.self]),
|
||||
isStoredInMemoryOnly: inMemory,
|
||||
allowsSave: true
|
||||
)
|
||||
|
||||
let container = try? ModelContainer(
|
||||
for: WeblogEntry.self,
|
||||
for: WeblogEntry.self, WeblogTag.self,
|
||||
configurations: configuration
|
||||
)
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue