Compare commits

...

42 commits
1.1.0 ... main

Author SHA1 Message Date
Otavio Cordeiro
59f79e55c5 Add ADR-017: Direct Distribution Outside App Store 2026-01-22 05:30:17 -03:00
Otavio Cordeiro
46fd069259 Break EditorView into smaller methods 2026-01-15 06:41:40 -03:00
Otavio Cordeiro
d4aecbe8ed Update Swift version used by SwiftFormat 2026-01-14 13:10:46 -03:00
Otavio Cordeiro
3ed032e8df Run SwiftFormat without modifying files 2026-01-14 13:03:05 -03:00
Otavio Cordeiro
afdd145a63 Add Build Phases for both SwiftLint and SwiftFormat 2026-01-14 12:30:53 -03:00
Otavio Cordeiro
ee174ab836 Add SwiftLint 2026-01-14 12:30:53 -03:00
Otavio Cordeiro
d3b596c26f Split GitHub Actions in separate files 2026-01-13 12:36:24 -03:00
Otavio Cordeiro
1a105917b2 Hide copy and share actions for draft posts 2026-01-05 12:56:25 -03:00
Otavio Cordeiro
2de32c7160 Add steps to install and upgrade to release notes 2026-01-05 12:52:18 -03:00
Otavio Cordeiro
b9e3362d1d Bump version to 1.5 2025-12-29 09:11:56 +01:00
Otavio Cordeiro
eaef589399 Select First Tag Suggestion using TAB 2025-12-28 09:36:46 +01:00
Otávio
4d95a7d40f Update README.md 2025-12-25 11:25:40 +01:00
Otávio Cordeiro
c9b6d624a2 Bump version to 1.4 2025-12-24 21:16:19 +01:00
Otávio Cordeiro
5e0ad765e1 Use user’s timezone for blog post time 2025-12-24 21:06:53 +01:00
Otávio Cordeiro
e1098916ab Update README with Ko-Fi link 2025-12-23 11:05:40 +01:00
Otavio Cordeiro
a97a1981e0 Bump version to 1.3 2025-12-22 22:58:22 +01:00
Otavio Cordeiro
833f9e920d Add new revamped drag-and-drop area 2025-12-22 22:43:10 +01:00
Otavio Cordeiro
07ea603267 Add SwiftUI Preview for picture drag-and-drop 2025-12-22 22:43:10 +01:00
Otavio Cordeiro
eedd369c37 Deleted stale tags before fetching new blog posts 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
c1aaf3af68 Update Ko-Fi URL with username instead of ID 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
3a86d153ea Handle line break in slugified() 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
8572716a73 Add StringSlugTests 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
5a2ebf5017 Add StringWeblogTests 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
c35418211d Add URLAddressTests 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
a52511547c Convert Foundation Extension Testst o Swift Testing 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
d4aa1f066f Update WeblogRequestFactory Tests 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
91435ff1ce Add Divider between Editor sections 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
793903352c Apply cosmetic changes to the editor layout 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
c837a2b788 Move window sizes to Scenes 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
ef2ab718d7 Add “Live with Tags” Preview 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
22956e834c Remove unnecessary method 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
1392ad0cc0 Add tag support to Weblog entries 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
d840caa026 Publish Weblog entry with the specified status 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
3365b19b8f Show the Weblog Entry Status in the Editor 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
958d410ddc Show status of weblog entry in list of posts 2025-12-22 11:31:33 +01:00
Otavio Cordeiro
e6d378dfef Add multiline support for Captions in Picture Upload and Editor windows 2025-12-21 14:54:21 +01:00
Otavio Cordeiro
17398e7dc0 Add GitHub Action to Run Unit Tests 2025-12-18 07:57:53 +01:00
Otavio Cordeiro
2aad39cf37 Add Test Targets to Scheme
This enables Command+U for running Unit Tests. It enables running tests
from the CLI as well, which is needed for running tests in CI.
2025-12-18 07:57:53 +01:00
Otavio Cordeiro
d54913af73 Bump version to 1.2 2025-12-17 22:39:05 +01:00
Otavio Cordeiro
d15f7a1be3 Improve dropzone when dragging an image from Finder 2025-12-17 22:30:54 +01:00
Otavio Cordeiro
f5a4bf476d Fix 401 error when publishing a new weblog entry
The add entry button in the toolbar wasn’t passing the selected address
to the lower layers. As result, Triton was making a network request to
create a new post without the address. In such cases, the API returns an
error, 401.

This commit ensures the address is getting passed down to the lower
layers before making the network request.
2025-12-17 21:56:35 +01:00
Otavio Cordeiro
1791cab97f Add GitHub Action for creating draft releases from tags
Implements automated draft release creation when version tags are
pushed.

The workflow:

- Triggers on tags matching the pattern [0-9]+.[0-9]+.[0-9]+
  (e.g., 1.0.0, 1.1.0)
- Finds all PRs merged since the previous tag using GitHub API
- Categorizes PRs into "fixes" (starting with Fix/Bugfix/Hotfix) and
  "other"
- Creates a draft release with formatted PR list including full URLs
- Leaves the release as draft for manual binary attachment before
  publishing
2025-12-17 18:33:37 +01:00
110 changed files with 3047 additions and 515 deletions

View file

@ -1,14 +1,12 @@
name: Pull Request Checks
name: Build
on:
pull_request:
branches:
- main
- develop
branches: [ main ]
jobs:
build-and-format:
name: Build and Format Check
build:
name: Build OMG
runs-on: macos-26
steps:
@ -45,14 +43,3 @@ jobs:
-destination 'platform=macOS' \
| xcpretty || exit 1
continue-on-error: false
- name: Install swiftformat
run: brew install swiftformat
- name: Check code formatting
run: |
swiftformat --lint .
if [ $? -ne 0 ]; then
echo "❌ Code formatting issues detected. Please run 'swiftformat .' locally to fix."
exit 1
fi

126
.github/workflows/draft-release.yml vendored Normal file
View 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
View 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
View 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
View 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

View file

@ -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
View 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

View file

@ -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.).

View file

@ -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:

View file

@ -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.1;
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.1;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.OMG;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;

View file

@ -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

View file

@ -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
}

View file

@ -41,8 +41,8 @@ struct TritonScene: Scene {
#endif
.environment(makeAccountUpdateService())
.handlesExternalEvents(
preferring: Set(arrayLiteral: "viewer"),
allowing: Set(arrayLiteral: "*")
preferring: ["viewer"],
allowing: ["*"]
)
.onAppear {
environment

View file

@ -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 }

View file

@ -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()

View file

@ -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"
)
}

View file

@ -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))
}
}
}

View file

@ -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)
}

View file

@ -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 }
)
}

View file

@ -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
}()
}
}

View file

@ -19,7 +19,7 @@ public extension String {
/// ```
func slugified() -> String {
trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .whitespaces)
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: "-")
}

View file

@ -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()
}
}

View file

@ -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")!
}

View file

@ -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

View file

@ -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"
)
}
}

View file

@ -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

View file

@ -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"
)
}
}

View file

@ -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",
#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.jpg",
#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.jpg",
#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.jpg",
#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",
#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.jpg",
#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.jpg",
#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",
#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,9 +134,8 @@ final class URLImagePreviewTests: XCTestCase {
let result = url.imagePreviewURL
// Then
XCTAssertEqual(
result.absoluteString,
"https://example.com/a.preview.jpg",
#expect(
result.absoluteString == "https://example.com/a.preview.jpg",
"It should handle single character filenames and use .preview.jpg extension"
)
}

View file

@ -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,
"![My Image](https://example.com/image.jpg)",
#expect(
result == "![My Image](https://example.com/image.jpg)",
"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,
"![https://example.com/photo.png](https://example.com/photo.png)",
#expect(
result == "![https://example.com/photo.png](https://example.com/photo.png)",
"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,
"![](https://example.com/image.gif)",
#expect(
result == "![](https://example.com/image.gif)",
"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,
"![Café & Naïve résumé 🌟](https://example.com)",
#expect(
result == "![Café & Naïve résumé 🌟](https://example.com)",
"It should handle unicode characters and emojis in title correctly"
)
}

View file

@ -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
}

View file

@ -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"
)

View file

@ -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

View file

@ -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

View file

@ -3,5 +3,6 @@ import XCTest
final class NowTests: XCTestCase {
// swiftlint:disable:next empty_xctest_method
func testExample() throws {}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -5,12 +5,4 @@ public struct UploadPictureRequest: Encodable, Sendable {
// MARK: - Properties
let pic: String
// MARK: - Lifecycle
init(
pic: String
) {
self.pic = pic
}
}

View file

@ -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)",

View file

@ -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()
}
}

View file

@ -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"
)
}

View file

@ -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"
)
}

View file

@ -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
)

View file

@ -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
}

View file

@ -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

View file

@ -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"
)

View file

@ -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

View file

@ -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

View file

@ -3,5 +3,6 @@ import XCTest
final class PURLsTests: XCTestCase {
// swiftlint:disable:next empty_xctest_method
func testExample() throws {}
}

View file

@ -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
}

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -3,5 +3,6 @@ import XCTest
final class PastebinTests: XCTestCase {
// swiftlint:disable:next empty_xctest_method
func testExample() throws {}
}

View file

@ -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
}

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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
)
}

View file

@ -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
}
}

View file

@ -42,6 +42,7 @@ struct UploadPictureScene: Scene {
UploadView(
viewModel: viewModel
)
.frame(minWidth: 640, idealWidth: 640, maxWidth: 800)
.environment(\.viewModelFactory, environment.viewModelFactory)
.modelContainer(environment.modelContainer)
}

View file

@ -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)
}
}
)
}
}

View file

@ -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)
}
}

View file

@ -21,7 +21,7 @@ struct PicturesListView: View {
_pictures = .init(viewModel.fetchDescriptor())
}
// MARK; - Public
// MARK: - Public
var body: some View {
Group {

View file

@ -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

View file

@ -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() {

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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."
)

View file

@ -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())
}

View file

@ -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
)
}
}

View file

@ -17,7 +17,10 @@
)
}
func copy(_ data: Data, type: String) {
func copy(
_ data: Data,
type: String
) {
NSPasteboard
.general
.clearContents()

View file

@ -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
}

View file

@ -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()
)
}

View file

@ -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

View file

@ -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()
)
}

View file

@ -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)"
)
}
}

View file

@ -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"
)

View file

@ -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()
)

View file

@ -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

View file

@ -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

View file

@ -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 }
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -21,7 +21,7 @@ struct WeblogEntriesListView: View {
_entries = .init(viewModel.fetchDescriptor())
}
// MARK; - Public
// MARK: - Public
var body: some View {
Group {

View file

@ -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
)
)
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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)]
)
}
}

View file

@ -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
)
}
}

View file

@ -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
)

View file

@ -0,0 +1,8 @@
extension WeblogTag {
static func makeTag(
title: String
) -> WeblogTag {
.init(title: title)
}
}

View file

@ -54,6 +54,14 @@ public struct StorableEntry {
/// proper data organization and multi-address support in local storage.
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.
let tags: [String]
// MARK: - Lifecycle
/// Initializes a new storable entry with all required weblog entry data.
@ -69,6 +77,7 @@ public struct StorableEntry {
/// - status: The publication status of the entry.
/// - location: The URL slug for the entry.
/// - address: The domain where the entry is hosted.
/// - tags: An array of tags associated with the entry. Defaults to empty array.
public init(
id: String,
title: String,
@ -76,7 +85,8 @@ public struct StorableEntry {
date: Double,
status: String,
location: String,
address: String
address: String,
tags: [String]
) {
self.id = id
self.title = title
@ -85,5 +95,6 @@ public struct StorableEntry {
self.status = status
self.location = location
self.address = address
self.tags = tags
}
}

View file

@ -64,6 +64,17 @@ public final class WeblogEntry {
/// for multiple weblogs within the same app instance.
public private(set) var 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.
///
/// This property is optional to support migration from older database schemas
/// that didn't include tags. When nil, it should be treated as an empty array.
public private(set) var tags: [String]?
// MARK: - Unique constraints
/// Ensures each entry has a unique identifier in the database.
@ -87,6 +98,7 @@ public final class WeblogEntry {
/// - status: The publication status of the entry.
/// - location: The URL slug for the entry.
/// - address: The domain where the entry is hosted.
/// - tags: An optional array of tags associated with the entry. Defaults to nil (treated as empty array).
public init(
id: String,
title: String,
@ -94,7 +106,8 @@ public final class WeblogEntry {
date: Double,
status: String,
location: String,
address: String
address: String,
tags: [String]? = nil
) {
self.id = id
self.title = title
@ -103,5 +116,6 @@ public final class WeblogEntry {
self.status = status
self.location = location
self.address = address
self.tags = tags
}
}

Some files were not shown because too many files have changed in this diff Show more