mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
@@ -0,0 +1,162 @@
|
||||
# iOS App Clone Prompt for Django Links Application
|
||||
|
||||
Please create an iOS app called Heygo(in Chinese 黑狗) that replicates the functionality of my Django-based link management system. Here are the core features and functionalities to implement:
|
||||
|
||||
## Core Link Management Features
|
||||
|
||||
### 1. Link Model & Data Structure
|
||||
- Links with unique aliases (alphanumeric slugs)
|
||||
- Original URLs (supports template parameters like `{param,default=value}`)
|
||||
- Link types: Regular Links and Custom Pages (with Markdown content), links with templates become Template type, so all the types are(Link, Custom, Template)
|
||||
- Click tracking with timestamps
|
||||
- Tags for categorization, optional
|
||||
- Creation and update timestamps
|
||||
- Description field, optional
|
||||
|
||||
### 2. Main Views & Functionality
|
||||
|
||||
#### Link List / Home page View
|
||||
- Display all links in a list with sorting options (alias, type, URL, clicks, date)
|
||||
- Search and quick go functionality, when typing in the search bar, it filters out on the existing links with fuzz search, when press enter, then open browser with the original url
|
||||
|
||||
#### Link Creation/Editing
|
||||
- Form to create new links with alias validation
|
||||
- Support for both regular URLs and custom markdown content
|
||||
- Template URL support with parameter placeholders
|
||||
- One more more Tags assignment, when user typing tags, it should auto complete based on existing tag list, auto create new tag on saving Link
|
||||
- Duplicate alias prevention
|
||||
- Real-time alias availability checking
|
||||
|
||||
#### Link Detail View
|
||||
- Comprehensive link information display
|
||||
- Click analytics with charts (daily/weekly/monthly views)
|
||||
- Time period selection (3 months, 6 months, 1 year, all time)
|
||||
- Change history log
|
||||
- For custom links: rendered markdown content
|
||||
|
||||
### 3. URL Redirection System
|
||||
- Short URL redirection (`/alias` → original URL)
|
||||
- Template parameter support (`/alias/param` for parameterized URLs)
|
||||
- Click logging for analytics
|
||||
- Error handling for non-existent aliases
|
||||
|
||||
### 4. Analytics & Tracking
|
||||
- Click count tracking per link
|
||||
- Detailed click logs with timestamps
|
||||
- Visual charts showing click trends over time
|
||||
- Change history tracking
|
||||
|
||||
### 5. Additional Features
|
||||
- Database backup capabilities to icloud
|
||||
- Search across links and aliases
|
||||
- Tag-based filtering
|
||||
- Custom markdown pages with full rendering
|
||||
- Help documentation
|
||||
|
||||
### 6. Technical Requirements
|
||||
- Core Data for local storage
|
||||
- Charts framework for analytics visualization
|
||||
- Markdown rendering for custom content
|
||||
- Network requests for URL validation
|
||||
- Share extensions for adding links from other apps
|
||||
- Dark mode support
|
||||
- Accessibility features
|
||||
- i18n multi language support, now supports Chinese and English
|
||||
|
||||
### 7. UI/UX Considerations
|
||||
- Clean, modern interface similar to link management tools
|
||||
- Quick access to popular links
|
||||
- Intuitive navigation between list, detail, and creation views
|
||||
- Visual feedback for user actions
|
||||
- Offline functionality where possible
|
||||
- Pull-to-refresh for data updates
|
||||
|
||||
### 8. Advanced Features to Include
|
||||
- Backup and sync capabilities
|
||||
- Batch operations (multi-select delete/edit)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Core Data Model Structure
|
||||
```swift
|
||||
// Link Entity
|
||||
class Link: NSManagedObject {
|
||||
@NSManaged var alias: String
|
||||
@NSManaged var originalURL: String?
|
||||
@NSManaged var text: String?
|
||||
@NSManaged var linkType: String // "LINK" or "CUSTOM"
|
||||
@NSManaged var clickCount: Int32
|
||||
@NSManaged var createdAt: Date
|
||||
@NSManaged var updatedAt: Date
|
||||
@NSManaged var linkDescription: String?
|
||||
@NSManaged var tags: NSSet?
|
||||
@NSManaged var clickLogs: NSSet?
|
||||
@NSManaged var changeLogs: NSSet?
|
||||
}
|
||||
|
||||
// ClickLog Entity
|
||||
class ClickLog: NSManagedObject {
|
||||
@NSManaged var clickedAt: Date
|
||||
@NSManaged var link: Link
|
||||
}
|
||||
|
||||
// Tag Entity
|
||||
class Tag: NSManagedObject {
|
||||
@NSManaged var name: String
|
||||
@NSManaged var slug: String
|
||||
@NSManaged var tagDescription: String?
|
||||
@NSManaged var createdAt: Date
|
||||
@NSManaged var links: NSSet?
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features to Replicate
|
||||
|
||||
#### Template URL Processing
|
||||
- Parse URLs with parameters like `{query,default=search}`
|
||||
- Extract parameter names and default values
|
||||
- Allow dynamic URL generation with user input
|
||||
- Handle missing parameters gracefully
|
||||
|
||||
#### Analytics Dashboard
|
||||
- Implement charts using Charts framework
|
||||
- Support multiple time periods (3m, 6m, 1y, all time)
|
||||
- Calculate intervals (daily, weekly, monthly) based on date range
|
||||
- Real-time click tracking
|
||||
|
||||
#### Import/Export System
|
||||
- JSON format compatibility with Django backend
|
||||
- Validation of imported data
|
||||
- Conflict resolution for duplicate aliases
|
||||
- Progress indication for large imports
|
||||
|
||||
#### Search & Filtering
|
||||
- Real-time search across aliases and URLs
|
||||
- Tag-based filtering
|
||||
- Sort by multiple criteria (clicks, date, alphabetical)
|
||||
- Search history and suggestions
|
||||
|
||||
## Architecture Requirements
|
||||
|
||||
Please structure the app with proper MVC architecture, implement proper error handling, and ensure good performance with large numbers of links. Include comprehensive test coverage and follow iOS Human Interface Guidelines.
|
||||
|
||||
### Recommended Architecture
|
||||
- **MVVM** pattern with Combine framework
|
||||
- **Core Data** for persistent storage
|
||||
- **SwiftUI** for modern UI development
|
||||
- **Charts** framework for analytics
|
||||
- **Network** layer for URL validation and TTS integration
|
||||
|
||||
### Performance Considerations
|
||||
- Lazy loading for large link collections
|
||||
- Efficient Core Data queries with proper indexing
|
||||
- Background processing for analytics calculations
|
||||
- Memory management for image and data caching
|
||||
|
||||
### Testing Strategy
|
||||
- Unit tests for business logic
|
||||
- UI tests for critical user flows
|
||||
- Performance tests for large datasets
|
||||
- Accessibility tests for VoiceOver support
|
||||
|
||||
This comprehensive iOS app should provide all the functionality of the Django web application while taking advantage of iOS-specific features and following platform conventions.
|
||||
+1
-2
@@ -396,8 +396,7 @@ class ToolsView(View):
|
||||
template_name = 'links/tools.html'
|
||||
|
||||
def get(self, request):
|
||||
export_url = reverse('export_links')
|
||||
return render(request, self.template_name, {'export_url': export_url})
|
||||
return render(request, self.template_name)
|
||||
|
||||
def post(self, request):
|
||||
if 'export' in request.POST:
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
|
||||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
||||
build/
|
||||
DerivedData/
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
## App packaging
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
#
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
#
|
||||
# Pods/
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# Accio dependency management
|
||||
Dependencies/
|
||||
.accio/
|
||||
|
||||
# fastlane
|
||||
#
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Code Injection
|
||||
#
|
||||
# After new code Injection tools there's a generated folder /iOSInjectionProject
|
||||
# https://github.com/johnno1962/injectionforxcode
|
||||
|
||||
iOSInjectionProject/
|
||||
|
||||
# Xcode Patch
|
||||
*.xcodeproj/*
|
||||
!*.xcodeproj/project.pbxproj
|
||||
!*.xcodeproj/xcshareddata/
|
||||
!*.xcodeproj/project.xcworkspace/
|
||||
*.xcodeproj/project.xcworkspace/*
|
||||
!*.xcodeproj/project.xcworkspace/contents.xcworkspacedata
|
||||
!*.xcodeproj/project.xcworkspace/xcshareddata/
|
||||
|
||||
# IDEDocumentVersioning
|
||||
/.LSOverride
|
||||
|
||||
# Simulator
|
||||
/.Simulator
|
||||
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
#
|
||||
# AppCode
|
||||
.idea/
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
|
||||
# Vim
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Emacs
|
||||
*~
|
||||
\#*\#
|
||||
/.emacs.desktop
|
||||
/.emacs.desktop.lock
|
||||
*.elc
|
||||
auto-save-list
|
||||
tramp
|
||||
.\#*
|
||||
|
||||
# Local environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Core Data
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# Backup files
|
||||
*~.nib/
|
||||
*.swp
|
||||
*~
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# iOS
|
||||
*.ipa
|
||||
*.dSYM
|
||||
*.dSYM.zip
|
||||
|
||||
# macOS
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Heygo specific
|
||||
# Add any app-specific files you want to ignore
|
||||
Screenshots/
|
||||
Logs/
|
||||
Config/development.plist
|
||||
Config/staging.plist
|
||||
@@ -0,0 +1,424 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
35CECEB72E503305000D0A0A /* SpotlightSearchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEB62E503305000D0A0A /* SpotlightSearchManager.swift */; };
|
||||
35CECEB92E506588000D0A0A /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEB82E506588000D0A0A /* TagDetailView.swift */; };
|
||||
35CECEBB2E5065AB000D0A0A /* TagEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEBA2E5065AB000D0A0A /* TagEditView.swift */; };
|
||||
35CECEBD2E507F51000D0A0A /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEBC2E507F51000D0A0A /* SettingsView.swift */; };
|
||||
B001 /* HeygoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* HeygoApp.swift */; };
|
||||
B002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* ContentView.swift */; };
|
||||
B003 /* LinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* LinkListView.swift */; };
|
||||
B004 /* LinkDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F004 /* LinkDetailView.swift */; };
|
||||
B005 /* LinkFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F005 /* LinkFormView.swift */; };
|
||||
B006 /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F006 /* AnalyticsView.swift */; };
|
||||
B007 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F007 /* SearchView.swift */; };
|
||||
B008 /* LinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F008 /* LinkViewModel.swift */; };
|
||||
B009 /* TagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F009 /* TagManager.swift */; };
|
||||
B00A /* URLTemplateProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00A /* URLTemplateProcessor.swift */; };
|
||||
B00B /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00B /* MarkdownRenderer.swift */; };
|
||||
B00C /* AnalyticsCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00C /* AnalyticsCalculator.swift */; };
|
||||
B00D /* Link+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00D /* Link+CoreDataClass.swift */; };
|
||||
B00E /* Tag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00E /* Tag+CoreDataClass.swift */; };
|
||||
B00F /* ClickLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00F /* ClickLog+CoreDataClass.swift */; };
|
||||
B010 /* ChangeLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F010 /* ChangeLog+CoreDataClass.swift */; };
|
||||
B011 /* en in Resources */ = {isa = PBXBuildFile; fileRef = F011 /* en */; };
|
||||
B012 /* zh-Hans in Resources */ = {isa = PBXBuildFile; fileRef = F012 /* zh-Hans */; };
|
||||
B013 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F014 /* PersistenceController.swift */; };
|
||||
B014 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = V001 /* CoreDataModel.xcdatamodeld */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
35CECEB62E503305000D0A0A /* SpotlightSearchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightSearchManager.swift; sourceTree = "<group>"; };
|
||||
35CECEB82E506588000D0A0A /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = "<group>"; };
|
||||
35CECEBA2E5065AB000D0A0A /* TagEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagEditView.swift; sourceTree = "<group>"; };
|
||||
35CECEBC2E507F51000D0A0A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
F001 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = "<group>"; };
|
||||
F002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
F003 /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = "<group>"; };
|
||||
F004 /* LinkDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkDetailView.swift; sourceTree = "<group>"; };
|
||||
F005 /* LinkFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkFormView.swift; sourceTree = "<group>"; };
|
||||
F006 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = "<group>"; };
|
||||
F007 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
F008 /* LinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkViewModel.swift; sourceTree = "<group>"; };
|
||||
F009 /* TagManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagManager.swift; sourceTree = "<group>"; };
|
||||
F00A /* URLTemplateProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTemplateProcessor.swift; sourceTree = "<group>"; };
|
||||
F00B /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = "<group>"; };
|
||||
F00C /* AnalyticsCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCalculator.swift; sourceTree = "<group>"; };
|
||||
F00D /* Link+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Link+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
F00E /* Tag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
F00F /* ClickLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClickLog+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
F010 /* ChangeLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChangeLog+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
F011 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Heygo/Resources/en.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; };
|
||||
F012 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "Heygo/Resources/zh-Hans.lproj/Localizable.strings"; sourceTree = SOURCE_ROOT; };
|
||||
F013 /* Heygo/Resources/Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Heygo/Resources/Info.plist; sourceTree = SOURCE_ROOT; };
|
||||
F014 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
|
||||
F015 /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = "<group>"; };
|
||||
P001 /* Heygo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Heygo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
PHASEFRAME /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
GAPP /* Heygo */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
GVIEW /* Views */,
|
||||
GVM /* ViewModels */,
|
||||
GMODEL /* Models */,
|
||||
GUTIL /* Utils */,
|
||||
GRES /* Resources */,
|
||||
F001 /* HeygoApp.swift */,
|
||||
F002 /* ContentView.swift */,
|
||||
);
|
||||
path = Heygo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
GMODEL /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
V001 /* CoreDataModel.xcdatamodeld */,
|
||||
F00D /* Link+CoreDataClass.swift */,
|
||||
F00E /* Tag+CoreDataClass.swift */,
|
||||
F00F /* ClickLog+CoreDataClass.swift */,
|
||||
F010 /* ChangeLog+CoreDataClass.swift */,
|
||||
F014 /* PersistenceController.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
GPROD /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
P001 /* Heygo.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
GRES /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F011 /* en */,
|
||||
F012 /* zh-Hans */,
|
||||
F013 /* Heygo/Resources/Info.plist */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
GROOT = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
GAPP /* Heygo */,
|
||||
GPROD /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
GUTIL /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
35CECEB62E503305000D0A0A /* SpotlightSearchManager.swift */,
|
||||
F009 /* TagManager.swift */,
|
||||
F00A /* URLTemplateProcessor.swift */,
|
||||
F00B /* MarkdownRenderer.swift */,
|
||||
F00C /* AnalyticsCalculator.swift */,
|
||||
);
|
||||
path = Utils;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
GVIEW /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
35CECEBC2E507F51000D0A0A /* SettingsView.swift */,
|
||||
35CECEBA2E5065AB000D0A0A /* TagEditView.swift */,
|
||||
35CECEB82E506588000D0A0A /* TagDetailView.swift */,
|
||||
F003 /* LinkListView.swift */,
|
||||
F004 /* LinkDetailView.swift */,
|
||||
F005 /* LinkFormView.swift */,
|
||||
F006 /* AnalyticsView.swift */,
|
||||
F007 /* SearchView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
GVM /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F008 /* LinkViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
T001 /* Heygo */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CLCONFTARGET /* Build configuration list for PBXNativeTarget "Heygo" */;
|
||||
buildPhases = (
|
||||
PHASESOURCES /* Sources */,
|
||||
PHASERES /* Resources */,
|
||||
PHASEFRAME /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Heygo;
|
||||
productName = Heygo;
|
||||
productReference = P001 /* Heygo.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
PROJ /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1640;
|
||||
};
|
||||
buildConfigurationList = CLCONFPROJ /* Build configuration list for PBXProject "Heygo" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
"zh-Hans",
|
||||
);
|
||||
mainGroup = GROOT;
|
||||
productRefGroup = GPROD /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
T001 /* Heygo */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
PHASERES /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B011 /* en in Resources */,
|
||||
B012 /* zh-Hans in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
PHASESOURCES /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B001 /* HeygoApp.swift in Sources */,
|
||||
B002 /* ContentView.swift in Sources */,
|
||||
B003 /* LinkListView.swift in Sources */,
|
||||
35CECEB72E503305000D0A0A /* SpotlightSearchManager.swift in Sources */,
|
||||
B004 /* LinkDetailView.swift in Sources */,
|
||||
B005 /* LinkFormView.swift in Sources */,
|
||||
B006 /* AnalyticsView.swift in Sources */,
|
||||
B007 /* SearchView.swift in Sources */,
|
||||
35CECEB92E506588000D0A0A /* TagDetailView.swift in Sources */,
|
||||
B008 /* LinkViewModel.swift in Sources */,
|
||||
B009 /* TagManager.swift in Sources */,
|
||||
B00A /* URLTemplateProcessor.swift in Sources */,
|
||||
B00B /* MarkdownRenderer.swift in Sources */,
|
||||
B00C /* AnalyticsCalculator.swift in Sources */,
|
||||
B00D /* Link+CoreDataClass.swift in Sources */,
|
||||
B00E /* Tag+CoreDataClass.swift in Sources */,
|
||||
B00F /* ClickLog+CoreDataClass.swift in Sources */,
|
||||
35CECEBB2E5065AB000D0A0A /* TagEditView.swift in Sources */,
|
||||
B010 /* ChangeLog+CoreDataClass.swift in Sources */,
|
||||
B013 /* PersistenceController.swift in Sources */,
|
||||
B014 /* CoreDataModel.xcdatamodeld in Sources */,
|
||||
35CECEBD2E507F51000D0A0A /* SettingsView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
CDBG /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
INFOPLIST_FILE = Heygo/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CDBG_T /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = AQ879ZQMD2;
|
||||
INFOPLIST_FILE = Heygo/Resources/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = HeyGo;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.heygo;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CREL /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
INFOPLIST_FILE = Heygo/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CREL_T /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = AQ879ZQMD2;
|
||||
INFOPLIST_FILE = Heygo/Resources/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = HeyGo;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.heygo;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
CLCONFPROJ /* Build configuration list for PBXProject "Heygo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CDBG /* Debug */,
|
||||
CREL /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CLCONFTARGET /* Build configuration list for PBXNativeTarget "Heygo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CDBG_T /* Debug */,
|
||||
CREL_T /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCVersionGroup section */
|
||||
V001 /* CoreDataModel.xcdatamodeld */ = {
|
||||
isa = XCVersionGroup;
|
||||
children = (
|
||||
F015 /* CoreDataModel.xcdatamodel */,
|
||||
);
|
||||
currentVersion = F015 /* CoreDataModel.xcdatamodel */;
|
||||
path = CoreDataModel.xcdatamodeld;
|
||||
sourceTree = "<group>";
|
||||
versionGroupType = wrapper.xcdatamodel;
|
||||
};
|
||||
/* End XCVersionGroup section */
|
||||
};
|
||||
rootObject = PROJ /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A10000001A000001 /* HeygoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000002 /* HeygoApp.swift */; };
|
||||
A10000001A000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000004 /* ContentView.swift */; };
|
||||
A10000001A000005 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000001A000006 /* Assets.xcassets */; };
|
||||
A10000001A000007 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000001A000008 /* Preview Assets.xcassets */; };
|
||||
A10000001A000009 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00000A /* CoreDataModel.xcdatamodeld */; };
|
||||
A10000001A00000C /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00000D /* PersistenceController.swift */; };
|
||||
A10000001A00000E /* LinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00000F /* LinkListView.swift */; };
|
||||
A10000001A000010 /* LinkDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000011 /* LinkDetailView.swift */; };
|
||||
A10000001A000012 /* LinkFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000013 /* LinkFormView.swift */; };
|
||||
A10000001A000014 /* LinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000015 /* LinkViewModel.swift */; };
|
||||
A10000001A000016 /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000017 /* AnalyticsView.swift */; };
|
||||
A10000001A000018 /* TagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000019 /* TagManager.swift */; };
|
||||
A10000001A00001A /* URLTemplateProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00001B /* URLTemplateProcessor.swift */; };
|
||||
A10000001A00001C /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00001D /* SearchView.swift */; };
|
||||
A10000001A00001E /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00001F /* MarkdownRenderer.swift */; };
|
||||
A10000001A000020 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = A10000001A000021 /* Localizable.strings */; };
|
||||
A10000001A000024 /* Link+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000025 /* Link+CoreDataClass.swift */; };
|
||||
A10000001A000026 /* Tag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000027 /* Tag+CoreDataClass.swift */; };
|
||||
A10000001A000028 /* ClickLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000029 /* ClickLog+CoreDataClass.swift */; };
|
||||
A10000001A00002A /* ChangeLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00002B /* ChangeLog+CoreDataClass.swift */; };
|
||||
A10000001A00002C /* AnalyticsCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00002D /* AnalyticsCalculator.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A10000001A000001 /* Heygo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Heygo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A10000001A000002 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = "<group>"; };
|
||||
A10000001A000004 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
A10000001A000006 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A10000001A000008 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
A10000001A00000A /* CoreDataModel.xcdatamodeld */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodeld; path = CoreDataModel.xcdatamodeld; sourceTree = "<group>"; };
|
||||
A10000001A00000B /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = "<group>"; };
|
||||
A10000001A00000D /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
|
||||
A10000001A00000F /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = "<group>"; };
|
||||
A10000001A000011 /* LinkDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkDetailView.swift; sourceTree = "<group>"; };
|
||||
A10000001A000013 /* LinkFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkFormView.swift; sourceTree = "<group>"; };
|
||||
A10000001A000015 /* LinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkViewModel.swift; sourceTree = "<group>"; };
|
||||
A10000001A000017 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = "<group>"; };
|
||||
A10000001A000019 /* TagManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagManager.swift; sourceTree = "<group>"; };
|
||||
A10000001A00001B /* URLTemplateProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTemplateProcessor.swift; sourceTree = "<group>"; };
|
||||
A10000001A00001D /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
A10000001A00001F /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = "<group>"; };
|
||||
A10000001A000022 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A10000001A000023 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
A10000001A000030 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A10000001A000025 /* Link+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Link+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
A10000001A000027 /* Tag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
A10000001A000029 /* ClickLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClickLog+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
A10000001A00002B /* ChangeLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChangeLog+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
A10000001A00002D /* AnalyticsCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCalculator.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A10000001A0000FD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A10000001A000031 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A000039 /* Heygo */,
|
||||
A10000001A000038 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000001A000038 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A000001 /* Heygo.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000001A000039 /* Heygo */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A000002 /* HeygoApp.swift */,
|
||||
A10000001A000004 /* ContentView.swift */,
|
||||
A10000001A000032 /* Views */,
|
||||
A10000001A000033 /* ViewModels */,
|
||||
A10000001A000034 /* Models */,
|
||||
A10000001A000035 /* Utils */,
|
||||
A10000001A000036 /* Resources */,
|
||||
A10000001A000037 /* Preview Content */,
|
||||
);
|
||||
path = Heygo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000001A000032 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A00000F /* LinkListView.swift */,
|
||||
A10000001A000011 /* LinkDetailView.swift */,
|
||||
A10000001A000013 /* LinkFormView.swift */,
|
||||
A10000001A000017 /* AnalyticsView.swift */,
|
||||
A10000001A00001D /* SearchView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000001A000033 /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A000015 /* LinkViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000001A000034 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A00000A /* CoreDataModel.xcdatamodeld */,
|
||||
A10000001A00000D /* PersistenceController.swift */,
|
||||
A10000001A000025 /* Link+CoreDataClass.swift */,
|
||||
A10000001A000027 /* Tag+CoreDataClass.swift */,
|
||||
A10000001A000029 /* ClickLog+CoreDataClass.swift */,
|
||||
A10000001A00002B /* ChangeLog+CoreDataClass.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000001A000035 /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A000019 /* TagManager.swift */,
|
||||
A10000001A00001B /* URLTemplateProcessor.swift */,
|
||||
A10000001A00001F /* MarkdownRenderer.swift */,
|
||||
A10000001A00002D /* AnalyticsCalculator.swift */,
|
||||
);
|
||||
path = Utils;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000001A000036 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A000006 /* Assets.xcassets */,
|
||||
A10000001A000021 /* Localizable.strings */,
|
||||
A10000001A000030 /* Info.plist */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10000001A000037 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10000001A000008 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A10000001A0000FE /* Heygo */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A10000001A000108 /* Build configuration list for PBXNativeTarget "Heygo" */;
|
||||
buildPhases = (
|
||||
A10000001A000105 /* Sources */,
|
||||
A10000001A0000FD /* Frameworks */,
|
||||
A10000001A0000FC /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Heygo;
|
||||
productName = Heygo;
|
||||
productReference = A10000001A000001 /* Heygo.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A10000001A0000F8 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
A10000001A0000FE = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A10000001A000104 /* Build configuration list for PBXProject "Heygo" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
"zh-Hans",
|
||||
);
|
||||
mainGroup = A10000001A000031;
|
||||
productRefGroup = A10000001A000038 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A10000001A0000FE /* Heygo */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A10000001A0000FC /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000001A000007 /* Preview Assets.xcassets in Resources */,
|
||||
A10000001A000005 /* Assets.xcassets in Resources */,
|
||||
A10000001A000020 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A10000001A0000FD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A10000001A000105 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000001A000001 /* HeygoApp.swift in Sources */,
|
||||
A10000001A000003 /* ContentView.swift in Sources */,
|
||||
A10000001A000009 /* CoreDataModel.xcdatamodeld in Sources */,
|
||||
A10000001A00000C /* PersistenceController.swift in Sources */,
|
||||
A10000001A00000E /* LinkListView.swift in Sources */,
|
||||
A10000001A000010 /* LinkDetailView.swift in Sources */,
|
||||
A10000001A000012 /* LinkFormView.swift in Sources */,
|
||||
A10000001A000014 /* LinkViewModel.swift in Sources */,
|
||||
A10000001A000016 /* AnalyticsView.swift in Sources */,
|
||||
A10000001A000018 /* TagManager.swift in Sources */,
|
||||
A10000001A00001A /* URLTemplateProcessor.swift in Sources */,
|
||||
A10000001A00001C /* SearchView.swift in Sources */,
|
||||
A10000001A00001E /* MarkdownRenderer.swift in Sources */,
|
||||
A10000001A000024 /* Link+CoreDataClass.swift in Sources */,
|
||||
A10000001A000026 /* Tag+CoreDataClass.swift in Sources */,
|
||||
A10000001A000028 /* ClickLog+CoreDataClass.swift in Sources */,
|
||||
A10000001A00002A /* ChangeLog+CoreDataClass.swift in Sources */,
|
||||
A10000001A00002C /* AnalyticsCalculator.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
A10000001A000021 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
A10000001A000022 /* en */,
|
||||
A10000001A000023 /* zh-Hans */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A10000001A000106 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A10000001A000107 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A10000001A000109 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Heygo/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Heygo/Resources/Info.plist";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.heygo.links;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A10000001A00010A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Heygo/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Heygo/Resources/Info.plist";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.heygo.links;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A10000001A000104 /* Build configuration list for PBXProject "Heygo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A10000001A000106 /* Debug */,
|
||||
A10000001A000107 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A10000001A000108 /* Build configuration list for PBXNativeTarget "Heygo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A10000001A000109 /* Debug */,
|
||||
A10000001A00010A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCVersionGroup section */
|
||||
A10000001A00000A /* CoreDataModel.xcdatamodeld */ = {
|
||||
isa = XCVersionGroup;
|
||||
children = (
|
||||
A10000001A00000B /* CoreDataModel.xcdatamodel */,
|
||||
);
|
||||
currentVersion = A10000001A00000B /* CoreDataModel.xcdatamodel */;
|
||||
path = CoreDataModel.xcdatamodeld;
|
||||
sourceTree = "<group>";
|
||||
versionGroupType = wrapper.xcdatamodel;
|
||||
};
|
||||
/* End XCVersionGroup section */
|
||||
};
|
||||
rootObject = A10000001A0000F8 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
B001 /* HeygoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* HeygoApp.swift */; };
|
||||
B002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* ContentView.swift */; };
|
||||
B003 /* LinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* LinkListView.swift */; };
|
||||
B004 /* LinkDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F004 /* LinkDetailView.swift */; };
|
||||
B005 /* LinkFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F005 /* LinkFormView.swift */; };
|
||||
B006 /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F006 /* AnalyticsView.swift */; };
|
||||
B007 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F007 /* SearchView.swift */; };
|
||||
B008 /* LinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F008 /* LinkViewModel.swift */; };
|
||||
B009 /* TagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F009 /* TagManager.swift */; };
|
||||
B00A /* URLTemplateProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00A /* URLTemplateProcessor.swift */; };
|
||||
B00B /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00B /* MarkdownRenderer.swift */; };
|
||||
B00C /* AnalyticsCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00C /* AnalyticsCalculator.swift */; };
|
||||
B00D /* Link+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00D /* Link+CoreDataClass.swift */; };
|
||||
B00E /* Tag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00E /* Tag+CoreDataClass.swift */; };
|
||||
B00F /* ClickLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00F /* ClickLog+CoreDataClass.swift */; };
|
||||
B010 /* ChangeLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F010 /* ChangeLog+CoreDataClass.swift */; };
|
||||
B011 /* en Localizable.strings */ = {isa = PBXBuildFile; fileRef = F011 /* en */; };
|
||||
B012 /* zh-Hans Localizable.strings */ = {isa = PBXBuildFile; fileRef = F012 /* zh-Hans */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
P001 /* Heygo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Heygo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F001 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = "<group>"; };
|
||||
F002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
F003 /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = "<group>"; };
|
||||
F004 /* LinkDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkDetailView.swift; sourceTree = "<group>"; };
|
||||
F005 /* LinkFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkFormView.swift; sourceTree = "<group>"; };
|
||||
F006 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = "<group>"; };
|
||||
F007 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
F008 /* LinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkViewModel.swift; sourceTree = "<group>"; };
|
||||
F009 /* TagManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagManager.swift; sourceTree = "<group>"; };
|
||||
F00A /* URLTemplateProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTemplateProcessor.swift; sourceTree = "<group>"; };
|
||||
F00B /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = "<group>"; };
|
||||
F00C /* AnalyticsCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCalculator.swift; sourceTree = "<group>"; };
|
||||
F00D /* Link+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Link+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
F00E /* Tag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
F00F /* ClickLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClickLog+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
F010 /* ChangeLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChangeLog+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
F011 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Heygo/Resources/en.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; };
|
||||
F012 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = Heygo/Resources/zh-Hans.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; };
|
||||
F013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Heygo/Resources/Info.plist; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
GROOT = {isa = PBXGroup; children = ( GAPP, GPROD ); sourceTree = "<group>"; };
|
||||
GAPP /* Heygo */ = {isa = PBXGroup; path = Heygo; sourceTree = "<group>"; children = ( GVIEW, GVM, GMODEL, GUTIL, GRES, F001, F002 ); };
|
||||
GVIEW /* Views */ = {isa = PBXGroup; path = Views; sourceTree = "<group>"; children = ( F003, F004, F005, F006, F007 ); };
|
||||
GVM /* ViewModels */ = {isa = PBXGroup; path = ViewModels; sourceTree = "<group>"; children = ( F008 ); };
|
||||
GMODEL /* Models */ = {isa = PBXGroup; path = Models; sourceTree = "<group>"; children = ( F00D, F00E, F00F, F010 ); };
|
||||
GUTIL /* Utils */ = {isa = PBXGroup; path = Utils; sourceTree = "<group>"; children = ( F009, F00A, F00B, F00C ); };
|
||||
GRES /* Resources */ = {isa = PBXGroup; path = Resources; sourceTree = "<group>"; children = ( F011, F012, F013 ); };
|
||||
GPROD /* Products */ = {isa = PBXGroup; name = Products; sourceTree = "<group>"; children = ( P001 ); };
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
T001 /* Heygo */ = {isa = PBXNativeTarget; buildConfigurationList = CLCONFTARGET /* Build configuration list for PBXNativeTarget "Heygo" */; buildPhases = ( PHASESOURCES, PHASERES, PHASEFRAME ); buildRules = ( ); dependencies = ( ); name = Heygo; productName = Heygo; productReference = P001 /* Heygo.app */; productType = "com.apple.product-type.application"; };
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
PROJ /* Project object */ = {isa = PBXProject; attributes = { LastUpgradeCheck = 1500; }; buildConfigurationList = CLCONFPROJ /* Build configuration list for PBXProject "Heygo" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, "zh-Hans" ); mainGroup = GROOT; productRefGroup = GPROD; projectDirPath = ""; projectRoot = ""; targets = ( T001 ); };
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
PHASESOURCES /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( B001, B002, B003, B004, B005, B006, B007, B008, B009, B00A, B00B, B00C, B00D, B00E, B00F, B010 ); runOnlyForDeploymentPostprocessing = 0; };
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
PHASERES /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( B011, B012 ); runOnlyForDeploymentPostprocessing = 0; };
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
PHASEFRAME /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; };
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
CDBG /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { PRODUCT_NAME = "$(TARGET_NAME)"; INFOPLIST_FILE = Heygo/Resources/Info.plist; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 17.0; TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_VERSION = 5.0; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; MARKETING_VERSION = 1.0; }; name = Debug; };
|
||||
CREL /* Release */ = {isa = XCBuildConfiguration; buildSettings = { PRODUCT_NAME = "$(TARGET_NAME)"; INFOPLIST_FILE = Heygo/Resources/Info.plist; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 17.0; TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_VERSION = 5.0; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; MARKETING_VERSION = 1.0; }; name = Release; };
|
||||
CDBG_T /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { PRODUCT_NAME = "$(TARGET_NAME)"; INFOPLIST_FILE = Heygo/Resources/Info.plist; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 17.0; TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_VERSION = 5.0; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; MARKETING_VERSION = 1.0; }; name = Debug; };
|
||||
CREL_T /* Release */ = {isa = XCBuildConfiguration; buildSettings = { PRODUCT_NAME = "$(TARGET_NAME)"; INFOPLIST_FILE = Heygo/Resources/Info.plist; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 17.0; TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_VERSION = 5.0; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; MARKETING_VERSION = 1.0; }; name = Release; };
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
CLCONFPROJ /* Build configuration list for PBXProject "Heygo" */ = {isa = XCConfigurationList; buildConfigurations = ( CDBG, CREL ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
|
||||
CLCONFTARGET /* Build configuration list for PBXNativeTarget "Heygo" */ = {isa = XCConfigurationList; buildConfigurations = ( CDBG_T, CREL_T ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
};
|
||||
rootObject = PROJ /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
</Workspace>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>Heygo.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
LinkListView()
|
||||
.tabItem {
|
||||
Image(systemName: "link")
|
||||
Text("links")
|
||||
}
|
||||
|
||||
SearchView()
|
||||
.tabItem {
|
||||
Image(systemName: "magnifyingglass")
|
||||
Text("search")
|
||||
}
|
||||
|
||||
AnalyticsView()
|
||||
.tabItem {
|
||||
Image(systemName: "chart.bar")
|
||||
Text("analytics")
|
||||
}
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Image(systemName: "gear")
|
||||
Text("settings")
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
SpotlightDebugView()
|
||||
.tabItem {
|
||||
Image(systemName: "eye")
|
||||
Text("spotlight")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.environmentObject(linkViewModel)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowLinkContent"))) { notification in
|
||||
// Handle showing link content when opened from Spotlight
|
||||
if let link = notification.userInfo?["link"] as? Link {
|
||||
handleShowLinkContent(link: link)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowTemplateParameterInput"))) { notification in
|
||||
// Handle showing template parameter input when opened from Spotlight
|
||||
if let link = notification.userInfo?["link"] as? Link {
|
||||
handleShowTemplateParameterInput(link: link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleShowLinkContent(link: Link) {
|
||||
// For custom/template links, you might want to show a modal or navigate to a detail view
|
||||
// For now, this is a placeholder - you can implement the UI as needed
|
||||
print("Showing content for link: \(link.alias)")
|
||||
}
|
||||
|
||||
private func handleShowTemplateParameterInput(link: Link) {
|
||||
// For template links with parameters, show parameter input interface
|
||||
// For now, this is a placeholder - you can implement the UI as needed
|
||||
print("Showing template parameter input for link: \(link.alias)")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
.environmentObject(LinkViewModel())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
import CoreSpotlight
|
||||
|
||||
@main
|
||||
struct HeygoApp: App {
|
||||
let persistenceController = PersistenceController.shared
|
||||
@StateObject private var linkViewModel = LinkViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environmentObject(linkViewModel)
|
||||
.onContinueUserActivity(CSSearchableItemActionType) { userActivity in
|
||||
handleSpotlightSearch(userActivity: userActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSpotlightSearch(userActivity: NSUserActivity) {
|
||||
guard let alias = SpotlightSearchManager.handleSpotlightSearchSelection(userActivity: userActivity) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Open the link from Spotlight search
|
||||
SpotlightSearchManager.openLinkFromSpotlight(alias: alias, linkViewModel: linkViewModel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# New Localization Keys for Tag Detail Feature
|
||||
|
||||
The following localization keys need to be added to support the new Tag Detail functionality:
|
||||
|
||||
## Tag Detail View
|
||||
- `close` - "Close" button
|
||||
- `description` - "Description" section header
|
||||
- `statistics` - "Statistics" section header
|
||||
- `associated_links` - "Associated Links" label
|
||||
- `created` - "Created" label
|
||||
- `links` - "Links" section header
|
||||
- `no_links_with_tag` - "No links are using this tag yet"
|
||||
- `delete_tag` - "Delete Tag" alert title
|
||||
- `delete_tag_confirmation` - "Are you sure you want to delete this tag? This action cannot be undone."
|
||||
- `cannot_delete_tag_with_links` - "Cannot delete tag that has associated links"
|
||||
- `error` - "Error" alert title
|
||||
- `ok` - "OK" button
|
||||
- `cancel` - "Cancel" button
|
||||
- `delete` - "Delete" button
|
||||
|
||||
## Tag Edit View
|
||||
- `edit_tag` - "Edit Tag" navigation title
|
||||
- `tag_information` - "Tag Information" section header
|
||||
- `tag_name` - "Tag Name" label
|
||||
- `tag_name_cannot_be_changed` - "Tag name cannot be changed"
|
||||
- `tag_description` - "Tag Description" section header
|
||||
- `add_description_to_help_organize_links` - "Add a description to help organize your links"
|
||||
- `save` - "Save" button
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### English (en)
|
||||
```
|
||||
"close" = "Close";
|
||||
"description" = "Description";
|
||||
"statistics" = "Statistics";
|
||||
"associated_links" = "Associated Links";
|
||||
"created" = "Created";
|
||||
"links" = "Links";
|
||||
"no_links_with_tag" = "No links are using this tag yet";
|
||||
"delete_tag" = "Delete Tag";
|
||||
"delete_tag_confirmation" = "Are you sure you want to delete this tag? This action cannot be undone.";
|
||||
"cannot_delete_tag_with_links" = "Cannot delete tag that has associated links";
|
||||
"error" = "Error";
|
||||
"ok" = "OK";
|
||||
"cancel" = "Cancel";
|
||||
"delete" = "Delete";
|
||||
"edit_tag" = "Edit Tag";
|
||||
"tag_information" = "Tag Information";
|
||||
"tag_name" = "Tag Name";
|
||||
"tag_name_cannot_be_changed" = "Tag name cannot be changed";
|
||||
"tag_description" = "Tag Description";
|
||||
"add_description_to_help_organize_links" = "Add a description to help organize your links";
|
||||
"save" = "Save";
|
||||
```
|
||||
|
||||
### Chinese Simplified (zh_Hans)
|
||||
```
|
||||
"close" = "关闭";
|
||||
"description" = "描述";
|
||||
"statistics" = "统计";
|
||||
"associated_links" = "关联链接";
|
||||
"created" = "创建时间";
|
||||
"links" = "链接";
|
||||
"no_links_with_tag" = "暂无链接使用此标签";
|
||||
"delete_tag" = "删除标签";
|
||||
"delete_tag_confirmation" = "确定要删除此标签吗?此操作无法撤销。";
|
||||
"cannot_delete_tag_with_links" = "无法删除有关联链接的标签";
|
||||
"error" = "错误";
|
||||
"ok" = "确定";
|
||||
"cancel" = "取消";
|
||||
"delete" = "删除";
|
||||
"edit_tag" = "编辑标签";
|
||||
"tag_information" = "标签信息";
|
||||
"tag_name" = "标签名称";
|
||||
"tag_name_cannot_be_changed" = "标签名称无法更改";
|
||||
"tag_description" = "标签描述";
|
||||
"add_description_to_help_organize_links" = "添加描述以帮助整理您的链接";
|
||||
"save" = "保存";
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. Most common keys like "close", "cancel", "delete", "save", "error", "ok" might already exist in the app
|
||||
2. Check existing localization files before adding duplicates
|
||||
3. The `clicks` key used in LinkRowView should already exist from the analytics features
|
||||
4. The `link_details` key used in LinkDetailView should already exist
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(ChangeLog)
|
||||
public class ChangeLog: NSManagedObject {
|
||||
|
||||
}
|
||||
|
||||
extension ChangeLog {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<ChangeLog> {
|
||||
return NSFetchRequest<ChangeLog>(entityName: "ChangeLog")
|
||||
}
|
||||
|
||||
@NSManaged public var changedAt: Date
|
||||
@NSManaged public var changeDescription: String
|
||||
@NSManaged public var changeType: String
|
||||
@NSManaged public var link: Link
|
||||
|
||||
}
|
||||
|
||||
extension ChangeLog : Identifiable {
|
||||
|
||||
public var id: NSManagedObjectID {
|
||||
return self.objectID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(ClickLog)
|
||||
public class ClickLog: NSManagedObject {
|
||||
|
||||
}
|
||||
|
||||
extension ClickLog {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<ClickLog> {
|
||||
return NSFetchRequest<ClickLog>(entityName: "ClickLog")
|
||||
}
|
||||
|
||||
@NSManaged public var clickedAt: Date
|
||||
@NSManaged public var link: Link
|
||||
|
||||
}
|
||||
|
||||
extension ClickLog : Identifiable {
|
||||
|
||||
public var id: NSManagedObjectID {
|
||||
return self.objectID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23C71" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Link" representedClassName="Link" syncable="YES">
|
||||
<attribute name="alias" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="clickCount" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="linkDescription" optional="YES" attributeType="String"/>
|
||||
<attribute name="linkType" attributeType="String" defaultValueString="LINK"/>
|
||||
<attribute name="originalURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="text" optional="YES" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="changeLogs" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="ChangeLog" inverseName="link" inverseEntity="ChangeLog"/>
|
||||
<relationship name="clickLogs" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="ClickLog" inverseName="link" inverseEntity="ClickLog"/>
|
||||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="links" inverseEntity="Tag"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="alias"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Tag" representedClassName="Tag" syncable="YES">
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="slug" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="tagDescription" optional="YES" attributeType="String"/>
|
||||
<relationship name="links" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Link" inverseName="tags" inverseEntity="Link"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="slug"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="ClickLog" representedClassName="ClickLog" syncable="YES">
|
||||
<attribute name="clickedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="link" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Link" inverseName="clickLogs" inverseEntity="Link"/>
|
||||
</entity>
|
||||
<entity name="ChangeLog" representedClassName="ChangeLog" syncable="YES">
|
||||
<attribute name="changedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="changeDescription" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="changeType" attributeType="String" defaultValueString=""/>
|
||||
<relationship name="link" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Link" inverseName="changeLogs" inverseEntity="Link"/>
|
||||
</entity>
|
||||
</model>
|
||||
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Link)
|
||||
public class Link: NSManagedObject {
|
||||
|
||||
}
|
||||
|
||||
extension Link {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Link> {
|
||||
return NSFetchRequest<Link>(entityName: "Link")
|
||||
}
|
||||
|
||||
@NSManaged public var alias: String
|
||||
@NSManaged public var originalURL: String?
|
||||
@NSManaged public var text: String?
|
||||
@NSManaged public var linkType: String
|
||||
@NSManaged public var clickCount: Int32
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var updatedAt: Date
|
||||
@NSManaged public var linkDescription: String?
|
||||
@NSManaged public var tags: NSSet?
|
||||
@NSManaged public var clickLogs: NSSet?
|
||||
@NSManaged public var changeLogs: NSSet?
|
||||
|
||||
}
|
||||
|
||||
// MARK: Generated accessors for tags
|
||||
extension Link {
|
||||
|
||||
@objc(addTagsObject:)
|
||||
@NSManaged public func addToTags(_ value: Tag)
|
||||
|
||||
@objc(removeTagsObject:)
|
||||
@NSManaged public func removeFromTags(_ value: Tag)
|
||||
|
||||
@objc(addTags:)
|
||||
@NSManaged public func addToTags(_ values: NSSet)
|
||||
|
||||
@objc(removeTags:)
|
||||
@NSManaged public func removeFromTags(_ values: NSSet)
|
||||
|
||||
}
|
||||
|
||||
// MARK: Generated accessors for clickLogs
|
||||
extension Link {
|
||||
|
||||
@objc(addClickLogsObject:)
|
||||
@NSManaged public func addToClickLogs(_ value: ClickLog)
|
||||
|
||||
@objc(removeClickLogsObject:)
|
||||
@NSManaged public func removeFromClickLogs(_ value: ClickLog)
|
||||
|
||||
@objc(addClickLogs:)
|
||||
@NSManaged public func addToClickLogs(_ values: NSSet)
|
||||
|
||||
@objc(removeClickLogs:)
|
||||
@NSManaged public func removeFromClickLogs(_ values: NSSet)
|
||||
|
||||
}
|
||||
|
||||
// MARK: Generated accessors for changeLogs
|
||||
extension Link {
|
||||
|
||||
@objc(addChangeLogsObject:)
|
||||
@NSManaged public func addToChangeLogs(_ value: ChangeLog)
|
||||
|
||||
@objc(removeChangeLogsObject:)
|
||||
@NSManaged public func removeFromChangeLogs(_ value: ChangeLog)
|
||||
|
||||
@objc(addChangeLogs:)
|
||||
@NSManaged public func addToChangeLogs(_ values: NSSet)
|
||||
|
||||
@objc(removeChangeLogs:)
|
||||
@NSManaged public func removeFromChangeLogs(_ values: NSSet)
|
||||
|
||||
}
|
||||
|
||||
extension Link : Identifiable {
|
||||
|
||||
public var id: NSManagedObjectID {
|
||||
return self.objectID
|
||||
}
|
||||
|
||||
var linkTypeEnum: LinkType {
|
||||
get {
|
||||
return LinkType(rawValue: linkType) ?? .link
|
||||
}
|
||||
set {
|
||||
linkType = newValue.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
var sortedTags: [Tag] {
|
||||
let tagSet = tags as? Set<Tag> ?? []
|
||||
return tagSet.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var sortedClickLogs: [ClickLog] {
|
||||
let clickLogSet = clickLogs as? Set<ClickLog> ?? []
|
||||
return clickLogSet.sorted { $0.clickedAt > $1.clickedAt }
|
||||
}
|
||||
|
||||
var isTemplate: Bool {
|
||||
return originalURL?.contains("{") == true && originalURL?.contains("}") == true
|
||||
}
|
||||
|
||||
func hasParametersInURL() -> Bool {
|
||||
return isTemplate
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkType: String, CaseIterable {
|
||||
case link = "LINK"
|
||||
case custom = "CUSTOM"
|
||||
case template = "TEMPLATE"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .link:
|
||||
return NSLocalizedString("link", comment: "Link type")
|
||||
case .custom:
|
||||
return NSLocalizedString("custom", comment: "Custom type")
|
||||
case .template:
|
||||
return NSLocalizedString("template", comment: "Template type")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
static var preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
|
||||
// Create sample data for previews
|
||||
let sampleLink = Link(context: viewContext)
|
||||
sampleLink.alias = "example"
|
||||
sampleLink.originalURL = "https://example.com"
|
||||
sampleLink.linkType = "LINK"
|
||||
sampleLink.clickCount = 5
|
||||
sampleLink.createdAt = Date()
|
||||
sampleLink.updatedAt = Date()
|
||||
sampleLink.linkDescription = "An example link"
|
||||
|
||||
let sampleTag = Tag(context: viewContext)
|
||||
sampleTag.name = "Sample"
|
||||
sampleTag.slug = "sample"
|
||||
sampleTag.createdAt = Date()
|
||||
|
||||
sampleLink.addToTags(sampleTag)
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
container = NSPersistentContainer(name: "CoreDataModel")
|
||||
if inMemory {
|
||||
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||
}
|
||||
|
||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
||||
if let error = error as NSError? {
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save context
|
||||
extension PersistenceController {
|
||||
func save() {
|
||||
let context = container.viewContext
|
||||
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Tag)
|
||||
public class Tag: NSManagedObject {
|
||||
|
||||
}
|
||||
|
||||
extension Tag {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Tag> {
|
||||
return NSFetchRequest<Tag>(entityName: "Tag")
|
||||
}
|
||||
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var slug: String
|
||||
@NSManaged public var tagDescription: String?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var links: NSSet?
|
||||
|
||||
}
|
||||
|
||||
// MARK: Generated accessors for links
|
||||
extension Tag {
|
||||
|
||||
@objc(addLinksObject:)
|
||||
@NSManaged public func addToLinks(_ value: Link)
|
||||
|
||||
@objc(removeLinksObject:)
|
||||
@NSManaged public func removeFromLinks(_ value: Link)
|
||||
|
||||
@objc(addLinks:)
|
||||
@NSManaged public func addToLinks(_ values: NSSet)
|
||||
|
||||
@objc(removeLinks:)
|
||||
@NSManaged public func removeFromLinks(_ values: NSSet)
|
||||
|
||||
}
|
||||
|
||||
extension Tag : Identifiable {
|
||||
|
||||
public var id: NSManagedObjectID {
|
||||
return self.objectID
|
||||
}
|
||||
|
||||
var linkCount: Int {
|
||||
return links?.count ?? 0
|
||||
}
|
||||
|
||||
var sortedLinks: [Link] {
|
||||
let linkSet = links as? Set<Link> ?? []
|
||||
return linkSet.sorted { $0.alias < $1.alias }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Heygo</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,188 @@
|
||||
/* App */
|
||||
"heygo" = "Heygo";
|
||||
|
||||
/* Navigation */
|
||||
"links" = "Links";
|
||||
"search" = "Search";
|
||||
"analytics" = "Analytics";
|
||||
"close" = "Close";
|
||||
"done" = "Done";
|
||||
"cancel" = "Cancel";
|
||||
"save" = "Save";
|
||||
"edit" = "Edit";
|
||||
"delete" = "Delete";
|
||||
"add" = "Add";
|
||||
|
||||
/* Link Types */
|
||||
"link" = "Link";
|
||||
"custom" = "Custom";
|
||||
"template" = "Template";
|
||||
|
||||
/* Link Management */
|
||||
"add_link" = "Add Link";
|
||||
"edit_link" = "Edit Link";
|
||||
"delete_link" = "Delete Link";
|
||||
"link_details" = "Link Details";
|
||||
"alias" = "Alias";
|
||||
"original_url" = "Original URL";
|
||||
"description_optional" = "Description (Optional)";
|
||||
"link_type" = "Link Type";
|
||||
"markdown_content" = "Markdown Content";
|
||||
"template_help" = "Use {parameter} or {parameter,default=value} for templates";
|
||||
"invalid_template_format" = "Invalid template format. Use {parameter} or {parameter,default=value}";
|
||||
"invalid_base_url_format" = "Invalid base URL format";
|
||||
|
||||
/* Tags */
|
||||
"tags" = "Tags";
|
||||
"search_or_create_tag" = "Search or create tag";
|
||||
"selected_tag" = "Selected Tag";
|
||||
"all_tags" = "All Tags";
|
||||
"popular_tags" = "Popular Tags";
|
||||
"tag_name" = "Tag Name";
|
||||
"tag" = "Tag";
|
||||
|
||||
/* Tag Details */
|
||||
"tag_details" = "Tag Details";
|
||||
"edit_tag" = "Edit Tag";
|
||||
"delete_tag" = "Delete Tag";
|
||||
"delete_tag_confirmation" = "Are you sure you want to delete this tag? This action cannot be undone.";
|
||||
"cannot_delete_tag_with_links" = "Cannot delete tag that has associated links";
|
||||
"associated_links" = "Associated Links";
|
||||
"created" = "Created";
|
||||
"no_links_with_tag" = "No links are using this tag yet";
|
||||
|
||||
/* Tag Edit */
|
||||
"tag_information" = "Tag Information";
|
||||
"tag_name_cannot_be_changed" = "Tag name cannot be changed";
|
||||
"tag_description" = "Tag Description";
|
||||
"add_description_to_help_organize_links" = "Add a description to help organize your links";
|
||||
|
||||
/* Search */
|
||||
"search_links" = "Search links";
|
||||
"search_or_enter_url" = "Search or enter URL";
|
||||
"no_results_found" = "No results found";
|
||||
"open_as_url" = "Open as URL";
|
||||
"recent_searches" = "Recent Searches";
|
||||
"search_tips" = "Search Tips";
|
||||
"search_by_alias" = "Search by alias";
|
||||
"search_by_alias_description" = "Find links by their short names";
|
||||
"search_by_url" = "Search by URL";
|
||||
"search_by_url_description" = "Find links by their destination";
|
||||
"search_by_tag" = "Search by tag";
|
||||
"search_by_tag_description" = "Find links by their tags";
|
||||
|
||||
/* Filters */
|
||||
"filter" = "Filter";
|
||||
"filters" = "Filters";
|
||||
"filter_links" = "Filter Links";
|
||||
"link_types" = "Link Types";
|
||||
"clear_filters" = "Clear Filters";
|
||||
|
||||
/* Sorting */
|
||||
"date_created" = "Date Created";
|
||||
"date_updated" = "Date Updated";
|
||||
"type" = "Type";
|
||||
"clicks" = "Clicks";
|
||||
|
||||
/* Analytics */
|
||||
"analytics" = "Analytics";
|
||||
"statistics" = "Statistics";
|
||||
"description" = "Description";
|
||||
"total_links" = "Total Links";
|
||||
"total_clicks" = "Total Clicks";
|
||||
"avg_clicks_per_link" = "Avg Clicks per Link";
|
||||
"active_links" = "Active Links";
|
||||
"clicks_over_time" = "Clicks Over Time";
|
||||
"links_created" = "Links Created";
|
||||
"top_links" = "Top Links";
|
||||
"top_performing_links" = "Top Performing Links";
|
||||
"link_type_distribution" = "Link Type Distribution";
|
||||
"recent_activity" = "Recent Activity";
|
||||
"no_data_available" = "No data available";
|
||||
"no_data_for_period" = "No data for selected period";
|
||||
|
||||
/* Time Ranges */
|
||||
"1_week" = "1 Week";
|
||||
"1_month" = "1 Month";
|
||||
"3_months" = "3 Months";
|
||||
"6_months" = "6 Months";
|
||||
"1_year" = "1 Year";
|
||||
"all_time" = "All Time";
|
||||
"time_range" = "Time Range";
|
||||
"metric" = "Metric";
|
||||
|
||||
/* Template Parameters */
|
||||
"template_parameters" = "Template Parameters";
|
||||
"enter_parameters" = "Enter Parameters";
|
||||
"enter_value" = "Enter value";
|
||||
"open_with_parameters" = "Open with Parameters";
|
||||
"open_link" = "Open Link";
|
||||
|
||||
/* Content */
|
||||
"custom_content" = "Custom Content";
|
||||
"content_preview" = "Content Preview";
|
||||
"recent_clicks" = "Recent Clicks";
|
||||
"change_history" = "Change History";
|
||||
|
||||
/* Export */
|
||||
"export_data" = "Export Data";
|
||||
"export_analytics" = "Export Analytics";
|
||||
"export_options" = "Export Options";
|
||||
"format" = "Format";
|
||||
"include_chart_data" = "Include Chart Data";
|
||||
"include_top_links" = "Include Top Links";
|
||||
"preview" = "Preview";
|
||||
"export" = "Export";
|
||||
"refresh_data" = "Refresh Data";
|
||||
|
||||
/* Empty States */
|
||||
"no_links_found" = "No links found";
|
||||
"tap_plus_to_add_link" = "Tap + to add your first link";
|
||||
|
||||
/* Validation Messages */
|
||||
"alias_already_exists" = "Alias already exists";
|
||||
"alias_invalid_characters" = "Alias contains invalid characters";
|
||||
"invalid_url_format" = "Please enter a valid URL (e.g., https://example.com)";
|
||||
"tag_already_exists" = "Tag already exists";
|
||||
|
||||
/* Error Messages */
|
||||
"error" = "Error";
|
||||
"unclosed_bold_markers" = "Unclosed bold markers";
|
||||
"unclosed_italic_markers" = "Unclosed italic markers";
|
||||
"unclosed_code_markers" = "Unclosed code markers";
|
||||
"malformed_links" = "Malformed links";
|
||||
|
||||
/* Actions */
|
||||
"open" = "Open";
|
||||
"preview" = "Preview";
|
||||
"edit" = "Edit";
|
||||
|
||||
/* Statistics */
|
||||
"chart_data_points" = "Chart data points";
|
||||
|
||||
/* Basic Information */
|
||||
"basic_information" = "Basic Information";
|
||||
"actions" = "Actions";
|
||||
|
||||
/* Settings */
|
||||
"settings" = "Settings";
|
||||
"change_language" = "Change Language";
|
||||
"import_links" = "Import Links";
|
||||
"system_language" = "System Language";
|
||||
"english" = "English";
|
||||
"chinese_simplified" = "Chinese (Simplified)";
|
||||
|
||||
/* Import */
|
||||
"import_result" = "Import Result";
|
||||
"import_success" = "%d links imported successfully!";
|
||||
"import_success_with_skipped" = "%d links imported successfully! %d were skipped (already exist).";
|
||||
"import_skipped" = "%d entries were skipped (already exist).";
|
||||
"import_failed" = "%d entries failed to import.";
|
||||
"import_error" = "Import failed: %@";
|
||||
"import_parse_error" = "Failed to parse JSON file: %@";
|
||||
"file_access_error" = "Unable to access the selected file";
|
||||
"ok" = "OK";
|
||||
|
||||
/* Language Change */
|
||||
"language_changed" = "Language Changed";
|
||||
"restart_app_message" = "Please close and restart the app for the language change to take effect.";
|
||||
@@ -0,0 +1,192 @@
|
||||
/* App */
|
||||
"heygo" = "黑狗";
|
||||
|
||||
/* Navigation */
|
||||
"links" = "链接";
|
||||
"search" = "搜索";
|
||||
"analytics" = "分析";
|
||||
"close" = "关闭";
|
||||
"done" = "完成";
|
||||
"cancel" = "取消";
|
||||
"save" = "保存";
|
||||
"edit" = "编辑";
|
||||
"delete" = "删除";
|
||||
"add" = "添加";
|
||||
|
||||
/* Link Types */
|
||||
"link" = "链接";
|
||||
"custom" = "自定义";
|
||||
"template" = "模板";
|
||||
|
||||
/* Link Management */
|
||||
"add_link" = "添加链接";
|
||||
"edit_link" = "编辑链接";
|
||||
"delete_link" = "删除链接";
|
||||
"link_details" = "链接详情";
|
||||
"alias" = "别名";
|
||||
"original_url" = "原始网址";
|
||||
"description_optional" = "描述(可选)";
|
||||
"link_type" = "链接类型";
|
||||
"markdown_content" = "Markdown 内容";
|
||||
"template_help" = "使用 {参数} 或 {参数,default=值} 创建模板";
|
||||
"invalid_template_format" = "无效的模板格式。请使用 {参数} 或 {参数,default=值}";
|
||||
"invalid_base_url_format" = "无效的基础网址格式";
|
||||
|
||||
/* Tags */
|
||||
"tags" = "标签";
|
||||
"search_or_create_tag" = "搜索或创建标签";
|
||||
"selected_tag" = "已选标签";
|
||||
"all_tags" = "全部标签";
|
||||
"popular_tags" = "热门标签";
|
||||
"tag_name" = "标签名称";
|
||||
"tag" = "标签";
|
||||
|
||||
/* Tag Details */
|
||||
"tag_details" = "标签详情";
|
||||
"edit_tag" = "编辑标签";
|
||||
"delete_tag" = "删除标签";
|
||||
"delete_tag_confirmation" = "确定要删除此标签吗?此操作无法撤销。";
|
||||
"cannot_delete_tag_with_links" = "无法删除有关联链接的标签";
|
||||
"associated_links" = "关联链接";
|
||||
"created" = "创建时间";
|
||||
"no_links_with_tag" = "暂无链接使用此标签";
|
||||
|
||||
/* Tag Edit */
|
||||
"tag_information" = "标签信息";
|
||||
"tag_name_cannot_be_changed" = "标签名称无法更改";
|
||||
"tag_description" = "标签描述";
|
||||
"add_description_to_help_organize_links" = "添加描述以帮助整理您的链接";
|
||||
|
||||
/* Search */
|
||||
"search_links" = "搜索链接";
|
||||
"search_or_enter_url" = "搜索或输入网址";
|
||||
"no_results_found" = "未找到结果";
|
||||
"open_as_url" = "作为网址打开";
|
||||
"recent_searches" = "最近搜索";
|
||||
"search_tips" = "搜索技巧";
|
||||
"search_by_alias" = "按别名搜索";
|
||||
"search_by_alias_description" = "通过简短名称查找链接";
|
||||
"search_by_url" = "按网址搜索";
|
||||
"search_by_url_description" = "通过目标地址查找链接";
|
||||
"search_by_tag" = "按标签搜索";
|
||||
"search_by_tag_description" = "通过标签查找链接";
|
||||
|
||||
/* Filters */
|
||||
"filter" = "筛选";
|
||||
"filters" = "筛选器";
|
||||
"filter_links" = "筛选链接";
|
||||
"link_types" = "链接类型";
|
||||
"clear_filters" = "清除筛选";
|
||||
|
||||
/* Sorting */
|
||||
"date_created" = "创建日期";
|
||||
"date_updated" = "更新日期";
|
||||
"type" = "类型";
|
||||
"clicks" = "点击";
|
||||
|
||||
/* Analytics */
|
||||
"analytics" = "分析";
|
||||
"total_links" = "总链接数";
|
||||
"total_clicks" = "总点击数";
|
||||
"avg_clicks_per_link" = "平均点击数";
|
||||
"active_links" = "活跃链接";
|
||||
"clicks_over_time" = "点击趋势";
|
||||
"links_created" = "创建的链接";
|
||||
"top_links" = "热门链接";
|
||||
"top_performing_links" = "表现最佳的链接";
|
||||
"link_type_distribution" = "链接类型分布";
|
||||
"recent_activity" = "最近活动";
|
||||
"no_data_available" = "无可用数据";
|
||||
"no_data_for_period" = "所选时间段无数据";
|
||||
|
||||
/* Time Ranges */
|
||||
"1_week" = "1周";
|
||||
"1_month" = "1个月";
|
||||
"3_months" = "3个月";
|
||||
"6_months" = "6个月";
|
||||
"1_year" = "1年";
|
||||
"all_time" = "全部时间";
|
||||
"time_range" = "时间范围";
|
||||
"metric" = "指标";
|
||||
|
||||
/* Template Parameters */
|
||||
"template_parameters" = "模板参数";
|
||||
"enter_parameters" = "输入参数";
|
||||
"enter_value" = "输入值";
|
||||
"open_with_parameters" = "使用参数打开";
|
||||
"open_link" = "打开链接";
|
||||
|
||||
/* Content */
|
||||
"custom_content" = "自定义内容";
|
||||
"content_preview" = "内容预览";
|
||||
"recent_clicks" = "最近点击";
|
||||
"change_history" = "更改历史";
|
||||
|
||||
/* Export */
|
||||
"export_data" = "导出数据";
|
||||
"export_analytics" = "导出分析";
|
||||
"export_options" = "导出选项";
|
||||
"format" = "格式";
|
||||
"include_chart_data" = "包含图表数据";
|
||||
"include_top_links" = "包含热门链接";
|
||||
"preview" = "预览";
|
||||
"export" = "导出";
|
||||
"refresh_data" = "刷新数据";
|
||||
|
||||
/* Empty States */
|
||||
"no_links_found" = "未找到链接";
|
||||
"tap_plus_to_add_link" = "点击 + 添加您的第一个链接";
|
||||
|
||||
/* Validation Messages */
|
||||
"alias_already_exists" = "别名已存在";
|
||||
"alias_invalid_characters" = "别名包含无效字符";
|
||||
"tag_already_exists" = "标签已存在";
|
||||
|
||||
/* Error Messages */
|
||||
"error" = "错误";
|
||||
"unclosed_bold_markers" = "未闭合的粗体标记";
|
||||
"unclosed_italic_markers" = "未闭合的斜体标记";
|
||||
"unclosed_code_markers" = "未闭合的代码标记";
|
||||
"malformed_links" = "格式错误的链接";
|
||||
|
||||
/* Actions */
|
||||
"open" = "打开";
|
||||
"preview" = "预览";
|
||||
"edit" = "编辑";
|
||||
|
||||
/* Statistics */
|
||||
"chart_data_points" = "图表数据点";
|
||||
"statistics" = "统计信息";
|
||||
|
||||
/* Basic Information */
|
||||
"basic_information" = "基本信息";
|
||||
"actions" = "操作";
|
||||
|
||||
/* Settings */
|
||||
"settings" = "设置";
|
||||
"change_language" = "更改语言";
|
||||
"import_links" = "导入链接";
|
||||
"system_language" = "系统语言";
|
||||
"english" = "英语";
|
||||
"chinese_simplified" = "简体中文";
|
||||
|
||||
/* Import */
|
||||
"import_result" = "导入结果";
|
||||
"import_success" = "成功导入 %d 个链接!";
|
||||
"import_success_with_skipped" = "成功导入 %d 个链接!%d 个已跳过(已存在)。";
|
||||
"import_skipped" = "%d 个条目已跳过(已存在)。";
|
||||
"import_failed" = "%d 个条目导入失败。";
|
||||
"import_error" = "导入失败:%@";
|
||||
"import_parse_error" = "解析 JSON 文件失败:%@";
|
||||
"file_access_error" = "无法访问所选文件";
|
||||
"ok" = "确定";
|
||||
|
||||
/* Language Change */
|
||||
"language_changed" = "语言已更改";
|
||||
"restart_app_message" = "请关闭并重新启动应用程序以使语言更改生效。";
|
||||
|
||||
/* Validation Messages */
|
||||
"alias_already_exists" = "别名已存在";
|
||||
"alias_invalid_characters" = "别名包含无效字符";
|
||||
"invalid_url_format" = "请输入有效的网址(例如:https://example.com)";
|
||||
"tag_already_exists" = "标签已存在";
|
||||
@@ -0,0 +1,165 @@
|
||||
# Tag Detail Feature Implementation Summary
|
||||
|
||||
## Overview
|
||||
Added comprehensive tag management functionality with clickable tags in Link Detail view that navigate to dedicated Tag Detail pages.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. `Views/TagDetailView.swift`
|
||||
**Purpose**: Main tag detail page showing tag information and associated links
|
||||
**Key Features**:
|
||||
- Tag description display (if exists)
|
||||
- Statistics: link count and creation date
|
||||
- List of all links using the tag with click counts
|
||||
- Edit and delete actions in navigation bar
|
||||
- Delete protection when links are associated
|
||||
- Navigation to link details from tag view
|
||||
|
||||
### 2. `Views/TagEditView.swift`
|
||||
**Purpose**: Edit interface for tag descriptions
|
||||
**Key Features**:
|
||||
- Display non-editable tag name
|
||||
- Editable description field with TextEditor
|
||||
- Statistics display (link count, creation date)
|
||||
- Save/cancel functionality
|
||||
- Form-based layout with sections
|
||||
|
||||
### 3. `LOCALIZATION_KEYS.md`
|
||||
**Purpose**: Documentation of new localization strings needed
|
||||
**Contents**: English and Chinese translations for all new UI text
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. `Views/LinkDetailView.swift`
|
||||
**Changes Made**:
|
||||
- **Removed**: `recentClicksSection` and its reference in body
|
||||
- **Updated**: `tagsSection` to make tags clickable with NavigationLink to TagDetailView
|
||||
- **Preserved**: All analytics functionality including filtered click counts
|
||||
|
||||
**Before**:
|
||||
```swift
|
||||
// Tags were just static text displays
|
||||
Text(tag.name)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.2))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(6)
|
||||
|
||||
// Recent clicks section was displayed
|
||||
recentClicksSection
|
||||
```
|
||||
|
||||
**After**:
|
||||
```swift
|
||||
// Tags are now clickable NavigationLinks
|
||||
NavigationLink(destination: TagDetailView(tag: tag)) {
|
||||
Text(tag.name)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.2))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// Recent clicks section removed completely
|
||||
```
|
||||
|
||||
## Key Functionality
|
||||
|
||||
### Tag Detail Page Features
|
||||
1. **Information Display**:
|
||||
- Tag name as navigation title
|
||||
- Tag description (if exists)
|
||||
- Creation date
|
||||
- Associated link count
|
||||
|
||||
2. **Links Management**:
|
||||
- List of all links using the tag
|
||||
- Click counts for each link
|
||||
- Direct navigation to link details
|
||||
- Empty state when no links use the tag
|
||||
|
||||
3. **Actions**:
|
||||
- Edit button → Opens TagEditView
|
||||
- Delete button → Shows confirmation alert
|
||||
- Delete protection when links exist
|
||||
|
||||
### Tag Edit Features
|
||||
1. **Read-Only Information**:
|
||||
- Tag name (cannot be changed)
|
||||
- Link count and creation date
|
||||
|
||||
2. **Editable Fields**:
|
||||
- Tag description using TextEditor
|
||||
- Auto-saves to Core Data
|
||||
|
||||
3. **Form Layout**:
|
||||
- Sectioned form with headers and footers
|
||||
- Explanatory text for user guidance
|
||||
|
||||
### Enhanced Link Detail Experience
|
||||
1. **Improved Navigation**:
|
||||
- Tags now lead to dedicated detail pages
|
||||
- Bidirectional navigation (tag → links → tag)
|
||||
|
||||
2. **Cleaner Interface**:
|
||||
- Removed cluttered recent clicks section
|
||||
- Focus on analytics and actionable information
|
||||
|
||||
## User Experience Flow
|
||||
|
||||
### Discovering Tag Details
|
||||
1. User views a link in LinkDetailView
|
||||
2. User taps on any tag chip
|
||||
3. Navigates to TagDetailView for that tag
|
||||
4. Can see all links using that tag
|
||||
5. Can edit tag description or delete tag
|
||||
|
||||
### Managing Tags
|
||||
1. From TagDetailView, tap edit button
|
||||
2. TagEditView opens with editable description
|
||||
3. User can add/modify description
|
||||
4. Save returns to TagDetailView with updated info
|
||||
|
||||
### Protection Mechanisms
|
||||
1. **Delete Protection**: Tags with associated links cannot be deleted
|
||||
2. **Name Protection**: Tag names cannot be changed (maintains link relationships)
|
||||
3. **Validation**: Proper error handling for all operations
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Data Model Integration
|
||||
- Uses existing Tag+CoreDataClass with `tagDescription` property
|
||||
- Leverages existing relationships between Tag and Link entities
|
||||
- Maintains Core Data consistency with proper save operations
|
||||
|
||||
### Navigation Architecture
|
||||
- Uses SwiftUI NavigationLink for seamless navigation
|
||||
- Proper state management with @Environment and @EnvironmentObject
|
||||
- Sheet presentation for edit modals
|
||||
|
||||
### UI/UX Patterns
|
||||
- Consistent with existing app design language
|
||||
- Proper use of SF Symbols for icons
|
||||
- Accessible layout with proper semantic labels
|
||||
- Error handling with user-friendly alerts
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Users
|
||||
1. **Better Organization**: Can see which links use specific tags
|
||||
2. **Enhanced Discovery**: Easy navigation between related links
|
||||
3. **Tag Management**: Can add descriptions to remember tag purposes
|
||||
4. **Cleaner Interface**: Removed unnecessary recent clicks clutter
|
||||
|
||||
### For Developers
|
||||
1. **Extensible**: Easy to add more tag-related features
|
||||
2. **Maintainable**: Clean separation of concerns
|
||||
3. **Consistent**: Follows existing app patterns and conventions
|
||||
4. **Safe**: Proper data validation and error handling
|
||||
|
||||
This implementation provides a complete tag management system while maintaining the existing app's functionality and improving the overall user experience.
|
||||
@@ -0,0 +1,411 @@
|
||||
import Foundation
|
||||
|
||||
struct AnalyticsCalculator {
|
||||
static func calculateAnalytics(for links: [Link], timeRange: AnalyticsView.TimeRange, metric: AnalyticsView.Metric) -> AnalyticsData {
|
||||
let filteredData = filterDataByTimeRange(links: links, timeRange: timeRange)
|
||||
|
||||
return AnalyticsData(
|
||||
totalLinks: filteredData.totalLinks,
|
||||
totalClicks: filteredData.totalClicks,
|
||||
activeLinks: filteredData.activeLinks,
|
||||
averageClicksPerLink: filteredData.averageClicksPerLink,
|
||||
linksChange: calculatePercentageChange(current: Double(filteredData.totalLinks), previous: Double(filteredData.previousTotalLinks)),
|
||||
clicksChange: calculatePercentageChange(current: Double(filteredData.totalClicks), previous: Double(filteredData.previousTotalClicks)),
|
||||
avgClicksChange: calculatePercentageChange(current: filteredData.averageClicksPerLink, previous: filteredData.previousAverageClicksPerLink),
|
||||
activeLinksChange: calculatePercentageChange(current: Double(filteredData.activeLinks), previous: Double(filteredData.previousActiveLinks)),
|
||||
chartData: generateChartData(for: links, timeRange: timeRange, metric: metric),
|
||||
topLinks: getTopLinks(from: links, timeRange: timeRange),
|
||||
linkTypeDistribution: getLinkTypeDistribution(from: links, timeRange: timeRange),
|
||||
recentActivity: getRecentActivity(from: links, timeRange: timeRange)
|
||||
)
|
||||
}
|
||||
|
||||
private static func filterDataByTimeRange(links: [Link], timeRange: AnalyticsView.TimeRange) -> FilteredAnalyticsData {
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Current period
|
||||
let currentPeriodLinks: [Link]
|
||||
let currentClickLogs: [ClickLog]
|
||||
|
||||
if let dateRange = timeRange.dateRange {
|
||||
currentPeriodLinks = links.filter { link in
|
||||
dateRange.contains(link.createdAt)
|
||||
}
|
||||
|
||||
currentClickLogs = links.flatMap { link in
|
||||
link.sortedClickLogs.filter { clickLog in
|
||||
dateRange.contains(clickLog.clickedAt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// All time
|
||||
currentPeriodLinks = links
|
||||
currentClickLogs = links.flatMap { $0.sortedClickLogs }
|
||||
}
|
||||
|
||||
// Previous period (for comparison)
|
||||
let previousPeriodLinks: [Link]
|
||||
let previousClickLogs: [ClickLog]
|
||||
|
||||
if let dateRange = timeRange.dateRange {
|
||||
let duration = dateRange.duration
|
||||
let previousStartDate = calendar.date(byAdding: .second, value: -Int(duration), to: dateRange.start)!
|
||||
let previousEndDate = dateRange.start
|
||||
let previousDateRange = DateInterval(start: previousStartDate, end: previousEndDate)
|
||||
|
||||
previousPeriodLinks = links.filter { link in
|
||||
previousDateRange.contains(link.createdAt)
|
||||
}
|
||||
|
||||
previousClickLogs = links.flatMap { link in
|
||||
link.sortedClickLogs.filter { clickLog in
|
||||
previousDateRange.contains(clickLog.clickedAt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For all time, compare with half the data as "previous"
|
||||
let midpoint = links.count / 2
|
||||
previousPeriodLinks = Array(links.prefix(midpoint))
|
||||
previousClickLogs = previousPeriodLinks.flatMap { $0.sortedClickLogs }
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
let currentTotalLinks = currentPeriodLinks.count
|
||||
let currentTotalClicks = currentClickLogs.count
|
||||
let currentActiveLinks = currentPeriodLinks.filter { $0.clickCount > 0 }.count
|
||||
let currentAverageClicksPerLink = currentTotalLinks > 0 ? Double(currentTotalClicks) / Double(currentTotalLinks) : 0.0
|
||||
|
||||
let previousTotalLinks = previousPeriodLinks.count
|
||||
let previousTotalClicks = previousClickLogs.count
|
||||
let previousActiveLinks = previousPeriodLinks.filter { $0.clickCount > 0 }.count
|
||||
let previousAverageClicksPerLink = previousTotalLinks > 0 ? Double(previousTotalClicks) / Double(previousTotalLinks) : 0.0
|
||||
|
||||
return FilteredAnalyticsData(
|
||||
totalLinks: currentTotalLinks,
|
||||
totalClicks: currentTotalClicks,
|
||||
activeLinks: currentActiveLinks,
|
||||
averageClicksPerLink: currentAverageClicksPerLink,
|
||||
previousTotalLinks: previousTotalLinks,
|
||||
previousTotalClicks: previousTotalClicks,
|
||||
previousActiveLinks: previousActiveLinks,
|
||||
previousAverageClicksPerLink: previousAverageClicksPerLink
|
||||
)
|
||||
}
|
||||
|
||||
private static func calculatePercentageChange(current: Double, previous: Double) -> Double? {
|
||||
guard previous > 0 else { return nil }
|
||||
return ((current - previous) / previous) * 100
|
||||
}
|
||||
|
||||
private static func generateChartData(for links: [Link], timeRange: AnalyticsView.TimeRange, metric: AnalyticsView.Metric) -> [ChartDataPoint] {
|
||||
switch metric {
|
||||
case .clicks:
|
||||
return generateClicksChartData(for: links, timeRange: timeRange)
|
||||
case .links:
|
||||
return generateLinksChartData(for: links, timeRange: timeRange)
|
||||
case .topLinks:
|
||||
return generateTopLinksChartData(for: links, timeRange: timeRange)
|
||||
}
|
||||
}
|
||||
|
||||
private static func generateClicksChartData(for links: [Link], timeRange: AnalyticsView.TimeRange) -> [ChartDataPoint] {
|
||||
let calendar = Calendar.current
|
||||
let allClickLogs: [ClickLog]
|
||||
let dateRange: DateInterval
|
||||
|
||||
if let range = timeRange.dateRange {
|
||||
dateRange = range
|
||||
allClickLogs = links.flatMap { link in
|
||||
link.sortedClickLogs.filter { clickLog in
|
||||
range.contains(clickLog.clickedAt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// All time - create a range from first click to now
|
||||
let allClicks = links.flatMap { $0.sortedClickLogs }
|
||||
guard let firstClick = allClicks.min(by: { $0.clickedAt < $1.clickedAt }) else {
|
||||
return []
|
||||
}
|
||||
dateRange = DateInterval(start: firstClick.clickedAt, end: Date())
|
||||
allClickLogs = allClicks
|
||||
}
|
||||
|
||||
// Determine the grouping interval
|
||||
let component: Calendar.Component
|
||||
let dateFormat: String
|
||||
|
||||
let duration = dateRange.duration
|
||||
if duration <= 7 * 24 * 3600 { // 1 week
|
||||
component = .day
|
||||
dateFormat = "MMM d"
|
||||
} else if duration <= 90 * 24 * 3600 { // 3 months
|
||||
component = .day
|
||||
dateFormat = "MMM d"
|
||||
} else if duration <= 365 * 24 * 3600 { // 1 year
|
||||
component = .weekOfYear
|
||||
dateFormat = "MMM d"
|
||||
} else {
|
||||
component = .month
|
||||
dateFormat = "MMM yyyy"
|
||||
}
|
||||
|
||||
// Group clicks by time interval
|
||||
let groupedClicks = Dictionary(grouping: allClickLogs) { clickLog in
|
||||
calendar.dateInterval(of: component, for: clickLog.clickedAt)?.start ?? clickLog.clickedAt
|
||||
}
|
||||
|
||||
// Create chart data points
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = dateFormat
|
||||
|
||||
return groupedClicks.map { date, clicks in
|
||||
ChartDataPoint(
|
||||
date: date,
|
||||
count: clicks.count,
|
||||
label: formatter.string(from: date)
|
||||
)
|
||||
}.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
private static func generateLinksChartData(for links: [Link], timeRange: AnalyticsView.TimeRange) -> [ChartDataPoint] {
|
||||
let calendar = Calendar.current
|
||||
let filteredLinks: [Link]
|
||||
let dateRange: DateInterval
|
||||
|
||||
if let range = timeRange.dateRange {
|
||||
dateRange = range
|
||||
filteredLinks = links.filter { link in
|
||||
range.contains(link.createdAt)
|
||||
}
|
||||
} else {
|
||||
// All time
|
||||
guard let firstLink = links.min(by: { $0.createdAt < $1.createdAt }) else {
|
||||
return []
|
||||
}
|
||||
dateRange = DateInterval(start: firstLink.createdAt, end: Date())
|
||||
filteredLinks = links
|
||||
}
|
||||
|
||||
// Determine the grouping interval
|
||||
let component: Calendar.Component
|
||||
let dateFormat: String
|
||||
|
||||
let duration = dateRange.duration
|
||||
if duration <= 30 * 24 * 3600 { // 1 month
|
||||
component = .day
|
||||
dateFormat = "MMM d"
|
||||
} else if duration <= 365 * 24 * 3600 { // 1 year
|
||||
component = .weekOfYear
|
||||
dateFormat = "MMM d"
|
||||
} else {
|
||||
component = .month
|
||||
dateFormat = "MMM yyyy"
|
||||
}
|
||||
|
||||
// Group links by creation date
|
||||
let groupedLinks = Dictionary(grouping: filteredLinks) { link in
|
||||
calendar.dateInterval(of: component, for: link.createdAt)?.start ?? link.createdAt
|
||||
}
|
||||
|
||||
// Create chart data points
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = dateFormat
|
||||
|
||||
return groupedLinks.map { date, links in
|
||||
ChartDataPoint(
|
||||
date: date,
|
||||
count: links.count,
|
||||
label: formatter.string(from: date)
|
||||
)
|
||||
}.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
private static func generateTopLinksChartData(for links: [Link], timeRange: AnalyticsView.TimeRange) -> [ChartDataPoint] {
|
||||
let topLinks = getTopLinks(from: links, timeRange: timeRange)
|
||||
|
||||
return topLinks.enumerated().map { index, linkData in
|
||||
ChartDataPoint(
|
||||
date: Date(), // Not used for top links chart
|
||||
count: linkData.clicks,
|
||||
label: linkData.link.alias
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func getTopLinks(from links: [Link], timeRange: AnalyticsView.TimeRange, limit: Int = 10) -> [TopLinkData] {
|
||||
let filteredLinks: [Link]
|
||||
|
||||
if let dateRange = timeRange.dateRange {
|
||||
// Filter clicks within the time range
|
||||
filteredLinks = links.map { link in
|
||||
let filteredClickLogs = link.sortedClickLogs.filter { clickLog in
|
||||
dateRange.contains(clickLog.clickedAt)
|
||||
}
|
||||
|
||||
// Create a temporary link-like structure with filtered click count
|
||||
return (link, filteredClickLogs.count)
|
||||
}
|
||||
.filter { $0.1 > 0 } // Only include links with clicks in the period
|
||||
.sorted { $0.1 > $1.1 } // Sort by click count
|
||||
.prefix(limit)
|
||||
.map { $0.0 } // Extract the link
|
||||
} else {
|
||||
// All time
|
||||
filteredLinks = Array(links.sorted { $0.clickCount > $1.clickCount }.prefix(limit))
|
||||
}
|
||||
|
||||
return filteredLinks.map { link in
|
||||
let clickCount: Int
|
||||
if let dateRange = timeRange.dateRange {
|
||||
clickCount = link.sortedClickLogs.filter { dateRange.contains($0.clickedAt) }.count
|
||||
} else {
|
||||
clickCount = Int(link.clickCount)
|
||||
}
|
||||
|
||||
return TopLinkData(link: link, clicks: clickCount)
|
||||
}
|
||||
}
|
||||
|
||||
private static func getLinkTypeDistribution(from links: [Link], timeRange: AnalyticsView.TimeRange) -> [LinkTypeData] {
|
||||
let filteredLinks: [Link]
|
||||
|
||||
if let dateRange = timeRange.dateRange {
|
||||
filteredLinks = links.filter { link in
|
||||
dateRange.contains(link.createdAt)
|
||||
}
|
||||
} else {
|
||||
filteredLinks = links
|
||||
}
|
||||
|
||||
let typeGroups = Dictionary(grouping: filteredLinks) { $0.linkTypeEnum }
|
||||
|
||||
return typeGroups.map { type, links in
|
||||
LinkTypeData(type: type, count: links.count)
|
||||
}.sorted { $0.count > $1.count }
|
||||
}
|
||||
|
||||
private static func getRecentActivity(from links: [Link], timeRange: AnalyticsView.TimeRange, limit: Int = 20) -> [RecentActivity] {
|
||||
var activities: [RecentActivity] = []
|
||||
|
||||
// Add link creation activities
|
||||
let filteredLinks: [Link]
|
||||
if let dateRange = timeRange.dateRange {
|
||||
filteredLinks = links.filter { dateRange.contains($0.createdAt) }
|
||||
} else {
|
||||
filteredLinks = Array(links.sorted { $0.createdAt > $1.createdAt }.prefix(limit / 2))
|
||||
}
|
||||
|
||||
for link in filteredLinks {
|
||||
activities.append(RecentActivity(
|
||||
id: UUID(),
|
||||
type: .linkCreated,
|
||||
description: "Created link '\(link.alias)'",
|
||||
timestamp: link.createdAt,
|
||||
link: link
|
||||
))
|
||||
}
|
||||
|
||||
// Add click activities
|
||||
let allClickLogs: [ClickLog]
|
||||
if let dateRange = timeRange.dateRange {
|
||||
allClickLogs = links.flatMap { link in
|
||||
link.sortedClickLogs.filter { dateRange.contains($0.clickedAt) }
|
||||
}
|
||||
} else {
|
||||
allClickLogs = links.flatMap { $0.sortedClickLogs }.sorted { $0.clickedAt > $1.clickedAt }
|
||||
}
|
||||
|
||||
for clickLog in Array(allClickLogs.prefix(limit / 2)) {
|
||||
activities.append(RecentActivity(
|
||||
id: UUID(),
|
||||
type: .linkClicked,
|
||||
description: "Clicked link '\(clickLog.link.alias)'",
|
||||
timestamp: clickLog.clickedAt,
|
||||
link: clickLog.link
|
||||
))
|
||||
}
|
||||
|
||||
return Array(activities.sorted { $0.timestamp > $1.timestamp }.prefix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Data Structures
|
||||
|
||||
struct AnalyticsData {
|
||||
let totalLinks: Int
|
||||
let totalClicks: Int
|
||||
let activeLinks: Int
|
||||
let averageClicksPerLink: Double
|
||||
let linksChange: Double?
|
||||
let clicksChange: Double?
|
||||
let avgClicksChange: Double?
|
||||
let activeLinksChange: Double?
|
||||
let chartData: [ChartDataPoint]
|
||||
let topLinks: [TopLinkData]
|
||||
let linkTypeDistribution: [LinkTypeData]
|
||||
let recentActivity: [RecentActivity]
|
||||
}
|
||||
|
||||
private struct FilteredAnalyticsData {
|
||||
let totalLinks: Int
|
||||
let totalClicks: Int
|
||||
let activeLinks: Int
|
||||
let averageClicksPerLink: Double
|
||||
let previousTotalLinks: Int
|
||||
let previousTotalClicks: Int
|
||||
let previousActiveLinks: Int
|
||||
let previousAverageClicksPerLink: Double
|
||||
}
|
||||
|
||||
struct TopLinkData {
|
||||
let link: Link
|
||||
let clicks: Int
|
||||
}
|
||||
|
||||
struct LinkTypeData {
|
||||
let type: LinkType
|
||||
let count: Int
|
||||
}
|
||||
|
||||
struct RecentActivity: Identifiable {
|
||||
let id: UUID
|
||||
let type: ActivityType
|
||||
let description: String
|
||||
let timestamp: Date
|
||||
let link: Link?
|
||||
|
||||
enum ActivityType {
|
||||
case linkCreated
|
||||
case linkUpdated
|
||||
case linkClicked
|
||||
case linkDeleted
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .linkCreated:
|
||||
return "plus.circle"
|
||||
case .linkUpdated:
|
||||
return "pencil.circle"
|
||||
case .linkClicked:
|
||||
return "hand.tap"
|
||||
case .linkDeleted:
|
||||
return "trash.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .linkCreated:
|
||||
return .green
|
||||
case .linkUpdated:
|
||||
return .blue
|
||||
case .linkClicked:
|
||||
return .orange
|
||||
case .linkDeleted:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import SwiftUI
|
||||
@@ -0,0 +1,380 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
struct MarkdownRenderer {
|
||||
static func renderToAttributedString(_ markdown: String) -> AttributedString {
|
||||
// Basic markdown parsing
|
||||
var attributedString = AttributedString(markdown)
|
||||
|
||||
// Apply basic styling
|
||||
attributedString = processHeaders(attributedString)
|
||||
attributedString = processBold(attributedString)
|
||||
attributedString = processItalic(attributedString)
|
||||
attributedString = processCode(attributedString)
|
||||
attributedString = processLinks(attributedString)
|
||||
attributedString = processLists(attributedString)
|
||||
|
||||
return attributedString
|
||||
}
|
||||
|
||||
private static func processHeaders(_ text: AttributedString) -> AttributedString {
|
||||
var result = text
|
||||
let string = String(result.characters)
|
||||
|
||||
// Process headers (# ## ### etc.)
|
||||
// Use (?m) for multiline so ^ and $ match line boundaries; avoid deprecated anchorsMatchLines
|
||||
let headerPattern = "(?m)^(#{1,6})\\s+(.+)$"
|
||||
let regex = try? NSRegularExpression(pattern: headerPattern, options: [])
|
||||
|
||||
let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? []
|
||||
|
||||
for match in matches.reversed() {
|
||||
let headerLevelRange = match.range(at: 1)
|
||||
let headerTextRange = match.range(at: 2)
|
||||
|
||||
if let headerLevelSwiftRange = Range(headerLevelRange, in: string),
|
||||
let headerTextSwiftRange = Range(headerTextRange, in: string) {
|
||||
|
||||
let headerLevel = string[headerLevelSwiftRange].count
|
||||
let headerText = String(string[headerTextSwiftRange])
|
||||
|
||||
let fontSize: CGFloat = {
|
||||
switch headerLevel {
|
||||
case 1: return 24
|
||||
case 2: return 20
|
||||
case 3: return 18
|
||||
case 4: return 16
|
||||
case 5: return 14
|
||||
case 6: return 12
|
||||
default: return 16
|
||||
}
|
||||
}()
|
||||
|
||||
if let range = Range(match.range, in: result) {
|
||||
var headerAttributedText = AttributedString(headerText)
|
||||
headerAttributedText.font = .system(size: fontSize, weight: .bold)
|
||||
result.replaceSubrange(range, with: headerAttributedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func processBold(_ text: AttributedString) -> AttributedString {
|
||||
var result = text
|
||||
let string = String(result.characters)
|
||||
|
||||
// Process bold (**text** or __text__)
|
||||
let boldPattern = "(\\*\\*|__)(.+?)\\1"
|
||||
let regex = try? NSRegularExpression(pattern: boldPattern, options: [])
|
||||
|
||||
let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? []
|
||||
|
||||
for match in matches.reversed() {
|
||||
let boldTextRange = match.range(at: 2)
|
||||
|
||||
if let boldTextSwiftRange = Range(boldTextRange, in: string) {
|
||||
let boldText = String(string[boldTextSwiftRange])
|
||||
|
||||
if let range = Range(match.range, in: result) {
|
||||
var boldAttributedText = AttributedString(boldText)
|
||||
boldAttributedText.font = .boldSystemFont(ofSize: 16)
|
||||
result.replaceSubrange(range, with: boldAttributedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func processItalic(_ text: AttributedString) -> AttributedString {
|
||||
var result = text
|
||||
let string = String(result.characters)
|
||||
|
||||
// Process italic (*text* or _text_)
|
||||
let italicPattern = "(?<!\\*)\\*([^*]+)\\*(?!\\*)|(?<!_)_([^_]+)_(?!_)"
|
||||
let regex = try? NSRegularExpression(pattern: italicPattern, options: [])
|
||||
|
||||
let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? []
|
||||
|
||||
for match in matches.reversed() {
|
||||
// Get the captured group that actually matched
|
||||
let italicTextRange = match.range(at: 1).location != NSNotFound ? match.range(at: 1) : match.range(at: 2)
|
||||
|
||||
if let italicTextSwiftRange = Range(italicTextRange, in: string) {
|
||||
let italicText = String(string[italicTextSwiftRange])
|
||||
|
||||
if let range = Range(match.range, in: result) {
|
||||
var italicAttributedText = AttributedString(italicText)
|
||||
italicAttributedText.font = .italicSystemFont(ofSize: 16)
|
||||
result.replaceSubrange(range, with: italicAttributedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func processCode(_ text: AttributedString) -> AttributedString {
|
||||
var result = text
|
||||
let string = String(result.characters)
|
||||
|
||||
// Process inline code (`code`)
|
||||
let codePattern = "`([^`]+)`"
|
||||
let regex = try? NSRegularExpression(pattern: codePattern, options: [])
|
||||
|
||||
let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? []
|
||||
|
||||
for match in matches.reversed() {
|
||||
let codeTextRange = match.range(at: 1)
|
||||
|
||||
if let codeTextSwiftRange = Range(codeTextRange, in: string) {
|
||||
let codeText = String(string[codeTextSwiftRange])
|
||||
|
||||
if let range = Range(match.range, in: result) {
|
||||
var codeAttributedText = AttributedString(codeText)
|
||||
codeAttributedText.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||
codeAttributedText.backgroundColor = .gray.opacity(0.2)
|
||||
result.replaceSubrange(range, with: codeAttributedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func processLinks(_ text: AttributedString) -> AttributedString {
|
||||
var result = text
|
||||
let string = String(result.characters)
|
||||
|
||||
// Process links [text](url)
|
||||
let linkPattern = "\\[([^\\]]+)\\]\\(([^\\)]+)\\)"
|
||||
let regex = try? NSRegularExpression(pattern: linkPattern, options: [])
|
||||
|
||||
let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? []
|
||||
|
||||
for match in matches.reversed() {
|
||||
let linkTextRange = match.range(at: 1)
|
||||
let linkURLRange = match.range(at: 2)
|
||||
|
||||
if let linkTextSwiftRange = Range(linkTextRange, in: string),
|
||||
let linkURLSwiftRange = Range(linkURLRange, in: string) {
|
||||
|
||||
let linkText = String(string[linkTextSwiftRange])
|
||||
let linkURL = String(string[linkURLSwiftRange])
|
||||
|
||||
if let range = Range(match.range, in: result) {
|
||||
var linkAttributedText = AttributedString(linkText)
|
||||
linkAttributedText.foregroundColor = .blue
|
||||
linkAttributedText.underlineStyle = .single
|
||||
if let url = URL(string: linkURL) {
|
||||
linkAttributedText.link = url
|
||||
}
|
||||
result.replaceSubrange(range, with: linkAttributedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func processLists(_ text: AttributedString) -> AttributedString {
|
||||
var result = text
|
||||
let string = String(result.characters)
|
||||
|
||||
// Process unordered lists (- item or * item)
|
||||
let listPattern = "(?m)^\\s*[-*+]\\s+(.+)$"
|
||||
let regex = try? NSRegularExpression(pattern: listPattern, options: [])
|
||||
|
||||
let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? []
|
||||
|
||||
for match in matches.reversed() {
|
||||
let listItemRange = match.range(at: 1)
|
||||
|
||||
if let listItemSwiftRange = Range(listItemRange, in: string) {
|
||||
let listItemText = String(string[listItemSwiftRange])
|
||||
|
||||
if let range = Range(match.range, in: result) {
|
||||
let bulletText = "• " + listItemText
|
||||
var listAttributedText = AttributedString(bulletText)
|
||||
listAttributedText.font = .systemFont(ofSize: 16)
|
||||
result.replaceSubrange(range, with: listAttributedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Views
|
||||
|
||||
struct MarkdownView: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Text(MarkdownRenderer.renderToAttributedString(text))
|
||||
.textSelection(.enabled)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkdownEditor: View {
|
||||
@Binding var text: String
|
||||
@State private var isPreviewMode: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// Toolbar
|
||||
HStack {
|
||||
Button(action: {
|
||||
isPreviewMode.toggle()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: isPreviewMode ? "pencil" : "eye")
|
||||
Text(isPreviewMode ? "edit" : "preview")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !isPreviewMode {
|
||||
Menu {
|
||||
Button("bold") {
|
||||
insertMarkdown("**", "**")
|
||||
}
|
||||
Button("italic") {
|
||||
insertMarkdown("*", "*")
|
||||
}
|
||||
Button("code") {
|
||||
insertMarkdown("`", "`")
|
||||
}
|
||||
Button("link") {
|
||||
insertMarkdown("[", "](url)")
|
||||
}
|
||||
Button("header") {
|
||||
insertMarkdown("# ", "")
|
||||
}
|
||||
Button("list_item") {
|
||||
insertMarkdown("- ", "")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "textformat")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Content
|
||||
if isPreviewMode {
|
||||
MarkdownView(text: text)
|
||||
} else {
|
||||
TextEditor(text: $text)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertMarkdown(_ prefix: String, _ suffix: String) {
|
||||
text += prefix + "text" + suffix
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Markdown Utilities
|
||||
|
||||
extension MarkdownRenderer {
|
||||
static func extractPlainText(_ markdown: String) -> String {
|
||||
var plainText = markdown
|
||||
|
||||
// Remove headers
|
||||
plainText = plainText.replacingOccurrences(of: "^#{1,6}\\s+", with: "", options: .regularExpression)
|
||||
|
||||
// Remove bold and italic
|
||||
plainText = plainText.replacingOccurrences(of: "\\*\\*([^*]+)\\*\\*", with: "$1", options: .regularExpression)
|
||||
plainText = plainText.replacingOccurrences(of: "__([^_]+)__", with: "$1", options: .regularExpression)
|
||||
plainText = plainText.replacingOccurrences(of: "\\*([^*]+)\\*", with: "$1", options: .regularExpression)
|
||||
plainText = plainText.replacingOccurrences(of: "_([^_]+)_", with: "$1", options: .regularExpression)
|
||||
|
||||
// Remove inline code
|
||||
plainText = plainText.replacingOccurrences(of: "`([^`]+)`", with: "$1", options: .regularExpression)
|
||||
|
||||
// Remove links
|
||||
plainText = plainText.replacingOccurrences(of: "\\[([^\\]]+)\\]\\([^\\)]+\\)", with: "$1", options: .regularExpression)
|
||||
|
||||
// Remove list markers
|
||||
plainText = plainText.replacingOccurrences(of: "(?m)^\\s*[-*+]\\s+", with: "", options: .regularExpression)
|
||||
|
||||
return plainText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func estimateReadingTime(_ markdown: String) -> Int {
|
||||
let plainText = extractPlainText(markdown)
|
||||
let wordCount = plainText.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count
|
||||
let wordsPerMinute = 200 // Average reading speed
|
||||
return max(1, wordCount / wordsPerMinute)
|
||||
}
|
||||
|
||||
static func getWordCount(_ markdown: String) -> Int {
|
||||
let plainText = extractPlainText(markdown)
|
||||
return plainText.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count
|
||||
}
|
||||
|
||||
static func validateMarkdown(_ markdown: String) -> [MarkdownValidationError] {
|
||||
var errors: [MarkdownValidationError] = []
|
||||
|
||||
// Check for unclosed bold/italic markers
|
||||
let boldCount = markdown.components(separatedBy: "**").count - 1
|
||||
if boldCount % 2 != 0 {
|
||||
errors.append(.unclosedbold)
|
||||
}
|
||||
|
||||
let italicCount = markdown.components(separatedBy: "*").count - 1 - boldCount
|
||||
if italicCount % 2 != 0 {
|
||||
errors.append(.unclosedItalic)
|
||||
}
|
||||
|
||||
// Check for unclosed code blocks
|
||||
let codeCount = markdown.components(separatedBy: "`").count - 1
|
||||
if codeCount % 2 != 0 {
|
||||
errors.append(.unclosedCode)
|
||||
}
|
||||
|
||||
// Check for malformed links
|
||||
let malformedLinkPattern = "\\[[^\\]]*\\]\\([^\\)]*$|\\[[^\\]]*$\\)|^[^\\[]*\\]\\([^\\)]*\\)"
|
||||
|
||||
if let regex = try? NSRegularExpression(pattern: malformedLinkPattern, options: []) {
|
||||
let matches = regex.matches(in: markdown, options: [], range: NSRange(location: 0, length: markdown.utf16.count))
|
||||
if !matches.isEmpty {
|
||||
errors.append(.malformedLink)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
|
||||
enum MarkdownValidationError: Error, LocalizedError {
|
||||
case unclosedbold
|
||||
case unclosedItalic
|
||||
case unclosedCode
|
||||
case malformedLink
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unclosedbold:
|
||||
return NSLocalizedString("unclosed_bold_markers", comment: "Unclosed bold markers")
|
||||
case .unclosedItalic:
|
||||
return NSLocalizedString("unclosed_italic_markers", comment: "Unclosed italic markers")
|
||||
case .unclosedCode:
|
||||
return NSLocalizedString("unclosed_code_markers", comment: "Unclosed code markers")
|
||||
case .malformedLink:
|
||||
return NSLocalizedString("malformed_links", comment: "Malformed links")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import Foundation
|
||||
import CoreSpotlight
|
||||
import CoreData
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class SpotlightSearchManager {
|
||||
static let shared = SpotlightSearchManager()
|
||||
|
||||
private var lastFullIndexTime: Date?
|
||||
private let minimumIndexInterval: TimeInterval = 300 // 5 minutes between full re-indexes
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Permission and Setup
|
||||
|
||||
/// Check if Spotlight indexing is available
|
||||
func isSpotlightAvailable() -> Bool {
|
||||
return CSSearchableIndex.isIndexingAvailable()
|
||||
}
|
||||
|
||||
/// Initialize Spotlight indexing with proper permissions
|
||||
func initializeSpotlightIndexing() {
|
||||
guard isSpotlightAvailable() else {
|
||||
print("Spotlight indexing is not available on this device")
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any existing index first, then reindex
|
||||
clearAllIndexedLinks()
|
||||
|
||||
// Index all links after a short delay to ensure cleanup is complete
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
self.indexAllLinks()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Indexing
|
||||
|
||||
/// Index all links in Spotlight search
|
||||
func indexAllLinks() {
|
||||
// Rate limit full indexing to avoid overwhelming the system
|
||||
if let lastIndex = lastFullIndexTime,
|
||||
Date().timeIntervalSince(lastIndex) < minimumIndexInterval {
|
||||
print("Skipping full index - too soon since last index")
|
||||
return
|
||||
}
|
||||
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let request: NSFetchRequest<Link> = Link.fetchRequest()
|
||||
|
||||
do {
|
||||
let links = try context.fetch(request)
|
||||
let searchableItems = links.compactMap { createSearchableItem(for: $0) }
|
||||
|
||||
CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in
|
||||
if let error = error {
|
||||
print("Failed to index links: \(error.localizedDescription)")
|
||||
} else {
|
||||
print("Successfully indexed \(searchableItems.count) links")
|
||||
self.lastFullIndexTime = Date()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to fetch links for indexing: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Index a single link
|
||||
func indexLink(_ link: Link) {
|
||||
guard let searchableItem = createSearchableItem(for: link) else { return }
|
||||
|
||||
CSSearchableIndex.default().indexSearchableItems([searchableItem]) { error in
|
||||
if let error = error {
|
||||
print("Failed to index link \(link.alias): \(error.localizedDescription)")
|
||||
} else {
|
||||
print("Successfully indexed link: \(link.alias)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a link from Spotlight index
|
||||
func removeLink(withAlias alias: String) {
|
||||
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: [alias]) { error in
|
||||
if let error = error {
|
||||
print("Failed to remove link \(alias) from index: \(error.localizedDescription)")
|
||||
} else {
|
||||
print("Successfully removed link \(alias) from index")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all indexed links
|
||||
func clearAllIndexedLinks() {
|
||||
CSSearchableIndex.default().deleteAllSearchableItems { error in
|
||||
if let error = error {
|
||||
print("Failed to clear all indexed links: \(error.localizedDescription)")
|
||||
} else {
|
||||
print("Successfully cleared all indexed links")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Debug and Testing
|
||||
|
||||
/// Get status of indexed items (for debugging)
|
||||
func getIndexedItemsStatus(completion: @escaping (Int) -> Void) {
|
||||
var query = CSSearchQuery(queryString: "contentType == 'public.url'", attributes: ["kMDItemTitle"])
|
||||
query = CSSearchQuery(queryString: "*", attributes: nil)
|
||||
|
||||
var itemCount = 0
|
||||
|
||||
query.foundItemsHandler = { items in
|
||||
itemCount += items.count
|
||||
}
|
||||
|
||||
query.completionHandler = { error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
print("Error querying indexed items: \(error.localizedDescription)")
|
||||
completion(0)
|
||||
} else {
|
||||
completion(itemCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query.start()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func createSearchableItem(for link: Link) -> CSSearchableItem? {
|
||||
let attributeSet = CSSearchableItemAttributeSet(contentType: UTType.url)
|
||||
|
||||
// Set basic attributes
|
||||
attributeSet.title = "Heygo: \(link.alias)"
|
||||
|
||||
// Add URL if it's a link type
|
||||
if link.linkTypeEnum == .link, let urlString = link.originalURL, let url = URL(string: urlString) {
|
||||
attributeSet.contentURL = url
|
||||
// Add URL as part of the content description for search
|
||||
if let description = link.linkDescription, !description.isEmpty {
|
||||
attributeSet.contentDescription = "\(description) - \(urlString)"
|
||||
} else {
|
||||
attributeSet.contentDescription = urlString
|
||||
}
|
||||
} else {
|
||||
// For non-link types, just use the description
|
||||
attributeSet.contentDescription = link.linkDescription ?? ""
|
||||
}
|
||||
|
||||
// Add text content for custom/template types
|
||||
if let text = link.text, !text.isEmpty {
|
||||
attributeSet.textContent = text
|
||||
}
|
||||
|
||||
// Add comprehensive keywords for better search
|
||||
var keywords = [String]()
|
||||
|
||||
// Add the alias as primary keyword
|
||||
keywords.append(link.alias)
|
||||
|
||||
// Add individual characters of alias for prefix matching
|
||||
let aliasChars = Array(link.alias.lowercased())
|
||||
for i in 1...aliasChars.count {
|
||||
let prefix = String(aliasChars[0..<i])
|
||||
keywords.append(prefix)
|
||||
}
|
||||
|
||||
// Add tag names
|
||||
let tagNames = link.sortedTags.map { $0.name }
|
||||
keywords.append(contentsOf: tagNames)
|
||||
|
||||
// Add app identifier for filtering
|
||||
keywords.append("heygo")
|
||||
keywords.append("link")
|
||||
|
||||
// Add link type as keyword
|
||||
keywords.append(link.linkTypeEnum.displayName.lowercased())
|
||||
|
||||
attributeSet.keywords = keywords
|
||||
|
||||
// Add click count as a ranking signal
|
||||
attributeSet.rankingHint = NSNumber(value: link.clickCount)
|
||||
|
||||
// Add creation and modification dates
|
||||
attributeSet.contentCreationDate = link.createdAt
|
||||
attributeSet.contentModificationDate = link.updatedAt
|
||||
|
||||
// Set icon/thumbnail based on link type (using SF Symbols would be better, but text works)
|
||||
switch link.linkTypeEnum {
|
||||
case .link:
|
||||
// For links, we could set a specific thumbnail or leave it to the system
|
||||
break
|
||||
case .custom:
|
||||
// For custom content
|
||||
break
|
||||
case .template:
|
||||
// For templates
|
||||
break
|
||||
}
|
||||
|
||||
// Create searchable item with unique identifier
|
||||
let item = CSSearchableItem(uniqueIdentifier: link.alias, domainIdentifier: "heygo.links", attributeSet: attributeSet)
|
||||
|
||||
// Set expiration date (optional - remove after 30 days of no updates)
|
||||
item.expirationDate = Calendar.current.date(byAdding: .day, value: 30, to: link.updatedAt)
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Result Handling
|
||||
|
||||
extension SpotlightSearchManager {
|
||||
|
||||
/// Handle when user selects a spotlight search result
|
||||
static func handleSpotlightSearchSelection(userActivity: NSUserActivity) -> String? {
|
||||
guard userActivity.activityType == CSSearchableItemActionType,
|
||||
let uniqueIdentifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return uniqueIdentifier // This is the link alias
|
||||
}
|
||||
|
||||
/// Open link and record click
|
||||
static func openLinkFromSpotlight(alias: String, linkViewModel: LinkViewModel) {
|
||||
// Find the link by alias
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let request: NSFetchRequest<Link> = Link.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "alias == %@", alias)
|
||||
request.fetchLimit = 1
|
||||
|
||||
do {
|
||||
let links = try context.fetch(request)
|
||||
guard let link = links.first else {
|
||||
print("Link with alias '\(alias)' not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Record the click
|
||||
linkViewModel.recordClick(for: link)
|
||||
|
||||
// Handle different link types
|
||||
switch link.linkTypeEnum {
|
||||
case .link:
|
||||
if let urlString = link.originalURL, let url = URL(string: urlString) {
|
||||
// Open URL in default browser
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
if !success {
|
||||
print("Failed to open URL: \(url)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case .template:
|
||||
// For template links, check if they have parameters
|
||||
if let urlString = link.originalURL, link.hasParametersInURL() {
|
||||
// If template has parameters, show the app to handle parameter input
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.Name("ShowTemplateParameterInput"),
|
||||
object: nil,
|
||||
userInfo: ["link": link]
|
||||
)
|
||||
} else if let urlString = link.originalURL, let url = URL(string: urlString) {
|
||||
// If no parameters, treat as regular link
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
if !success {
|
||||
print("Failed to open template URL: \(url)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case .custom:
|
||||
// For custom types, show the content in the app
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.Name("ShowLinkContent"),
|
||||
object: nil,
|
||||
userInfo: ["link": link]
|
||||
)
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("Failed to fetch link: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class TagManager: ObservableObject {
|
||||
private let persistenceController = PersistenceController.shared
|
||||
|
||||
@Published var allTags: [Tag] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
init() {
|
||||
fetchTags()
|
||||
}
|
||||
|
||||
func fetchTags() {
|
||||
isLoading = true
|
||||
let context = persistenceController.container.viewContext
|
||||
let request: NSFetchRequest<Tag> = Tag.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Tag.name, ascending: true)]
|
||||
|
||||
do {
|
||||
allTags = try context.fetch(request)
|
||||
isLoading = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func createTag(name: String, description: String? = nil) -> Tag? {
|
||||
let context = persistenceController.container.viewContext
|
||||
|
||||
// Check if tag already exists
|
||||
if tagExists(name: name) {
|
||||
errorMessage = NSLocalizedString("tag_already_exists", comment: "Tag already exists")
|
||||
return nil
|
||||
}
|
||||
|
||||
let newTag = Tag(context: context)
|
||||
newTag.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
newTag.slug = generateSlug(from: name)
|
||||
newTag.tagDescription = description
|
||||
newTag.createdAt = Date()
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
fetchTags()
|
||||
return newTag
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func updateTag(_ tag: Tag, name: String, description: String?) -> Bool {
|
||||
let context = persistenceController.container.viewContext
|
||||
|
||||
// Check if name already exists (excluding current tag)
|
||||
if name != tag.name && tagExists(name: name) {
|
||||
errorMessage = NSLocalizedString("tag_already_exists", comment: "Tag already exists")
|
||||
return false
|
||||
}
|
||||
|
||||
tag.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
tag.slug = generateSlug(from: name)
|
||||
tag.tagDescription = description
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
fetchTags()
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deleteTag(_ tag: Tag) -> Bool {
|
||||
let context = persistenceController.container.viewContext
|
||||
|
||||
// Remove tag from all associated links
|
||||
if let links = tag.links as? Set<Link> {
|
||||
for link in links {
|
||||
link.removeFromTags(tag)
|
||||
}
|
||||
}
|
||||
|
||||
context.delete(tag)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
fetchTags()
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func findOrCreateTag(name: String) -> Tag? {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// First, try to find existing tag
|
||||
if let existingTag = findTag(name: trimmedName) {
|
||||
return existingTag
|
||||
}
|
||||
|
||||
// Create new tag if not found
|
||||
return createTag(name: trimmedName)
|
||||
}
|
||||
|
||||
func findTag(name: String) -> Tag? {
|
||||
return allTags.first { $0.name.lowercased() == name.lowercased() }
|
||||
}
|
||||
|
||||
func findTagBySlug(_ slug: String) -> Tag? {
|
||||
return allTags.first { $0.slug == slug }
|
||||
}
|
||||
|
||||
func searchTags(query: String) -> [Tag] {
|
||||
if query.isEmpty {
|
||||
return allTags
|
||||
}
|
||||
|
||||
return allTags.filter { tag in
|
||||
tag.name.localizedCaseInsensitiveContains(query) ||
|
||||
(tag.tagDescription?.localizedCaseInsensitiveContains(query) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
func getTagsWithPrefix(_ prefix: String) -> [Tag] {
|
||||
if prefix.isEmpty {
|
||||
return allTags
|
||||
}
|
||||
|
||||
return allTags.filter { tag in
|
||||
tag.name.lowercased().hasPrefix(prefix.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
func getMostPopularTags(limit: Int = 10) -> [Tag] {
|
||||
return allTags
|
||||
.sorted { $0.linkCount > $1.linkCount }
|
||||
.prefix(limit)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
func getRecentlyUsedTags(limit: Int = 5) -> [Tag] {
|
||||
// Get tags that were recently added to links
|
||||
let context = persistenceController.container.viewContext
|
||||
let request: NSFetchRequest<Link> = Link.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Link.updatedAt, ascending: false)]
|
||||
request.fetchLimit = limit * 3 // Fetch more links to get variety of tags
|
||||
|
||||
do {
|
||||
let recentLinks = try context.fetch(request)
|
||||
var tagCounts: [Tag: Int] = [:]
|
||||
|
||||
for link in recentLinks {
|
||||
for tag in link.sortedTags {
|
||||
tagCounts[tag, default: 0] += 1
|
||||
}
|
||||
}
|
||||
|
||||
return tagCounts
|
||||
.sorted { $0.value > $1.value }
|
||||
.prefix(limit)
|
||||
.map { $0.key }
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func getTagStatistics() -> TagStatistics {
|
||||
let totalTags = allTags.count
|
||||
let totalLinks = allTags.reduce(0) { $0 + $1.linkCount }
|
||||
let averageLinksPerTag = totalTags > 0 ? Double(totalLinks) / Double(totalTags) : 0.0
|
||||
let mostPopularTag = allTags.max { $0.linkCount < $1.linkCount }
|
||||
let unusedTags = allTags.filter { $0.linkCount == 0 }
|
||||
|
||||
return TagStatistics(
|
||||
totalTags: totalTags,
|
||||
totalLinks: totalLinks,
|
||||
averageLinksPerTag: averageLinksPerTag,
|
||||
mostPopularTag: mostPopularTag,
|
||||
unusedTagsCount: unusedTags.count
|
||||
)
|
||||
}
|
||||
|
||||
func mergeTags(_ sourceTags: [Tag], into targetTag: Tag) -> Bool {
|
||||
let context = persistenceController.container.viewContext
|
||||
|
||||
do {
|
||||
// Move all links from source tags to target tag
|
||||
for sourceTag in sourceTags {
|
||||
if let links = sourceTag.links as? Set<Link> {
|
||||
for link in links {
|
||||
link.removeFromTags(sourceTag)
|
||||
link.addToTags(targetTag)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete source tag
|
||||
context.delete(sourceTag)
|
||||
}
|
||||
|
||||
try context.save()
|
||||
fetchTags()
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupUnusedTags() -> Int {
|
||||
let unusedTags = allTags.filter { $0.linkCount == 0 }
|
||||
let context = persistenceController.container.viewContext
|
||||
|
||||
for tag in unusedTags {
|
||||
context.delete(tag)
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
fetchTags()
|
||||
return unusedTags.count
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helper Methods
|
||||
|
||||
private func tagExists(name: String) -> Bool {
|
||||
return findTag(name: name) != nil
|
||||
}
|
||||
|
||||
private func generateSlug(from name: String) -> String {
|
||||
return name
|
||||
.lowercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
.replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
struct TagStatistics {
|
||||
let totalTags: Int
|
||||
let totalLinks: Int
|
||||
let averageLinksPerTag: Double
|
||||
let mostPopularTag: Tag?
|
||||
let unusedTagsCount: Int
|
||||
}
|
||||
|
||||
struct TagSuggestion {
|
||||
let name: String
|
||||
let reason: String
|
||||
let confidence: Double
|
||||
}
|
||||
|
||||
// MARK: - Tag Suggestions
|
||||
|
||||
extension TagManager {
|
||||
func suggestTagsForLink(_ link: Link) -> [TagSuggestion] {
|
||||
var suggestions: [TagSuggestion] = []
|
||||
|
||||
// Suggest based on URL domain
|
||||
if let url = link.originalURL, let domain = extractDomain(from: url) {
|
||||
if let domainTag = suggestTagFromDomain(domain) {
|
||||
suggestions.append(domainTag)
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest based on link type
|
||||
let typeTag = TagSuggestion(
|
||||
name: link.linkTypeEnum.displayName.lowercased(),
|
||||
reason: NSLocalizedString("suggested_based_on_type", comment: "Suggested based on link type"),
|
||||
confidence: 0.8
|
||||
)
|
||||
suggestions.append(typeTag)
|
||||
|
||||
// Suggest based on alias keywords
|
||||
let aliasKeywords = extractKeywords(from: link.alias)
|
||||
for keyword in aliasKeywords {
|
||||
if keyword.count > 2 {
|
||||
suggestions.append(TagSuggestion(
|
||||
name: keyword,
|
||||
reason: NSLocalizedString("suggested_from_alias", comment: "Suggested from alias"),
|
||||
confidence: 0.6
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest based on description
|
||||
if let description = link.linkDescription {
|
||||
let descriptionKeywords = extractKeywords(from: description)
|
||||
for keyword in descriptionKeywords.prefix(3) {
|
||||
if keyword.count > 3 {
|
||||
suggestions.append(TagSuggestion(
|
||||
name: keyword,
|
||||
reason: NSLocalizedString("suggested_from_description", comment: "Suggested from description"),
|
||||
confidence: 0.5
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
.filter { suggestion in
|
||||
!allTags.contains { $0.name.lowercased() == suggestion.name.lowercased() }
|
||||
}
|
||||
.sorted { $0.confidence > $1.confidence }
|
||||
}
|
||||
|
||||
private func extractDomain(from url: String) -> String? {
|
||||
guard let urlObj = URL(string: url),
|
||||
let host = urlObj.host else { return nil }
|
||||
|
||||
let components = host.components(separatedBy: ".")
|
||||
return components.count >= 2 ? components[components.count - 2] : host
|
||||
}
|
||||
|
||||
private func suggestTagFromDomain(_ domain: String) -> TagSuggestion? {
|
||||
let commonDomainTags: [String: String] = [
|
||||
"github": "code",
|
||||
"stackoverflow": "programming",
|
||||
"youtube": "video",
|
||||
"twitter": "social",
|
||||
"facebook": "social",
|
||||
"linkedin": "professional",
|
||||
"medium": "articles",
|
||||
"wikipedia": "reference",
|
||||
"google": "search",
|
||||
"apple": "tech",
|
||||
"microsoft": "tech",
|
||||
"amazon": "shopping"
|
||||
]
|
||||
|
||||
if let tagName = commonDomainTags[domain.lowercased()] {
|
||||
return TagSuggestion(
|
||||
name: tagName,
|
||||
reason: NSLocalizedString("suggested_from_domain", comment: "Suggested from domain"),
|
||||
confidence: 0.7
|
||||
)
|
||||
}
|
||||
|
||||
return TagSuggestion(
|
||||
name: domain,
|
||||
reason: NSLocalizedString("suggested_from_domain", comment: "Suggested from domain"),
|
||||
confidence: 0.6
|
||||
)
|
||||
}
|
||||
|
||||
private func extractKeywords(from text: String) -> [String] {
|
||||
let words = text
|
||||
.lowercased()
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
// Filter out common stop words
|
||||
let stopWords = Set(["the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by"])
|
||||
|
||||
return words.filter { !stopWords.contains($0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import Foundation
|
||||
|
||||
struct URLTemplateProcessor {
|
||||
static func processTemplate(_ template: String, with parameters: [String: String]) -> String {
|
||||
var processedURL = template
|
||||
let extractedParams = extractParameters(from: template)
|
||||
|
||||
for param in extractedParams {
|
||||
// Create patterns to match both formats: {name} and {name, default=value}
|
||||
let patterns = [
|
||||
"\\{\(NSRegularExpression.escapedPattern(for: param.name))\\}",
|
||||
"\\{\(NSRegularExpression.escapedPattern(for: param.name))\\s*,\\s*default\\s*=\\s*\(NSRegularExpression.escapedPattern(for: param.defaultValue ?? ""))\\}"
|
||||
]
|
||||
|
||||
let value = parameters[param.name] ?? param.defaultValue ?? ""
|
||||
|
||||
for pattern in patterns {
|
||||
if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
|
||||
processedURL = regex.stringByReplacingMatches(in: processedURL, options: [], range: NSRange(location: 0, length: processedURL.utf16.count), withTemplate: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedURL
|
||||
}
|
||||
|
||||
static func extractParameters(from template: String) -> [TemplateParameter] {
|
||||
var parameters: [TemplateParameter] = []
|
||||
// Updated pattern to handle optional spaces around comma and default=
|
||||
let pattern = "\\{([^,}]+)(?:\\s*,\\s*default\\s*=\\s*([^}]+))?\\}"
|
||||
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
return parameters
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.utf16.count))
|
||||
|
||||
for match in matches {
|
||||
let nameRange = match.range(at: 1)
|
||||
let defaultRange = match.range(at: 2)
|
||||
|
||||
if let nameSwiftRange = Range(nameRange, in: template) {
|
||||
let name = String(template[nameSwiftRange]).trimmingCharacters(in: .whitespaces)
|
||||
var defaultValue: String? = nil
|
||||
|
||||
if defaultRange.location != NSNotFound,
|
||||
let defaultSwiftRange = Range(defaultRange, in: template) {
|
||||
defaultValue = String(template[defaultSwiftRange]).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
parameters.append(TemplateParameter(name: name, defaultValue: defaultValue))
|
||||
}
|
||||
}
|
||||
|
||||
return parameters
|
||||
}
|
||||
|
||||
static func validateTemplate(_ template: String) -> Bool {
|
||||
let extractedParams = extractParameters(from: template)
|
||||
|
||||
// Check if all placeholders are valid
|
||||
let pattern = "\\{[^{}]*\\}"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.utf16.count))
|
||||
|
||||
// Each match should correspond to a valid parameter
|
||||
for match in matches {
|
||||
if let matchRange = Range(match.range, in: template) {
|
||||
let matchString = String(template[matchRange])
|
||||
let isValid = extractedParams.contains { param in
|
||||
matchString.contains(param.name)
|
||||
}
|
||||
if !isValid {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
static func getRequiredParameters(from template: String) -> [String] {
|
||||
return extractParameters(from: template)
|
||||
.filter { $0.defaultValue == nil }
|
||||
.map { $0.name }
|
||||
}
|
||||
|
||||
static func hasParameters(_ template: String) -> Bool {
|
||||
return template.contains("{") && template.contains("}")
|
||||
}
|
||||
}
|
||||
|
||||
struct TemplateParameter {
|
||||
let name: String
|
||||
let defaultValue: String?
|
||||
|
||||
var isRequired: Bool {
|
||||
return defaultValue == nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Template Examples and Helpers
|
||||
extension URLTemplateProcessor {
|
||||
static let commonTemplates: [TemplateExample] = [
|
||||
TemplateExample(
|
||||
name: "Google Search",
|
||||
template: "https://google.com/search?q={query}",
|
||||
description: "Search Google with a custom query"
|
||||
),
|
||||
TemplateExample(
|
||||
name: "GitHub Repository",
|
||||
template: "https://github.com/{user}/{repo}",
|
||||
description: "Open a GitHub repository"
|
||||
),
|
||||
TemplateExample(
|
||||
name: "YouTube Video",
|
||||
template: "https://youtube.com/watch?v={videoId}",
|
||||
description: "Open a YouTube video"
|
||||
),
|
||||
TemplateExample(
|
||||
name: "Maps Location",
|
||||
template: "https://maps.apple.com/?q={location,default=New York}",
|
||||
description: "Open a location in Apple Maps"
|
||||
),
|
||||
TemplateExample(
|
||||
name: "Wikipedia Article",
|
||||
template: "https://en.wikipedia.org/wiki/{article}",
|
||||
description: "Open a Wikipedia article"
|
||||
)
|
||||
]
|
||||
|
||||
static func suggestTemplate(for url: String) -> String? {
|
||||
// Simple template suggestion based on common patterns
|
||||
if url.contains("google.com/search") {
|
||||
return url.replacingOccurrences(of: "q=[^&]*", with: "q={query}", options: .regularExpression)
|
||||
} else if url.contains("github.com") && url.components(separatedBy: "/").count >= 5 {
|
||||
let components = url.components(separatedBy: "/")
|
||||
if components.count >= 5 {
|
||||
return "https://github.com/{user}/{repo}"
|
||||
}
|
||||
} else if url.contains("youtube.com/watch") {
|
||||
return url.replacingOccurrences(of: "v=[^&]*", with: "v={videoId}", options: .regularExpression)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct TemplateExample {
|
||||
let name: String
|
||||
let template: String
|
||||
let description: String
|
||||
|
||||
var parameters: [TemplateParameter] {
|
||||
return URLTemplateProcessor.extractParameters(from: template)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
import CoreSpotlight
|
||||
|
||||
class LinkViewModel: ObservableObject {
|
||||
@Published var links: [Link] = []
|
||||
@Published var filteredLinks: [Link] = []
|
||||
@Published var searchText: String = "" {
|
||||
didSet {
|
||||
filterLinks()
|
||||
}
|
||||
}
|
||||
@Published var selectedSortOption: SortOption = .alias {
|
||||
didSet {
|
||||
sortLinks()
|
||||
}
|
||||
}
|
||||
@Published var selectedTag: Tag? {
|
||||
didSet {
|
||||
filterLinks()
|
||||
}
|
||||
}
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let persistenceController = PersistenceController.shared
|
||||
|
||||
enum SortOption: String, CaseIterable {
|
||||
case alias = "alias"
|
||||
case type = "linkType"
|
||||
case clicks = "clickCount"
|
||||
case dateCreated = "createdAt"
|
||||
case dateUpdated = "updatedAt"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .alias:
|
||||
return NSLocalizedString("alias", comment: "Sort by alias")
|
||||
case .type:
|
||||
return NSLocalizedString("type", comment: "Sort by type")
|
||||
case .clicks:
|
||||
return NSLocalizedString("clicks", comment: "Sort by clicks")
|
||||
case .dateCreated:
|
||||
return NSLocalizedString("date_created", comment: "Sort by date created")
|
||||
case .dateUpdated:
|
||||
return NSLocalizedString("date_updated", comment: "Sort by date updated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
fetchLinks()
|
||||
initializeSpotlightSearch()
|
||||
}
|
||||
|
||||
private func initializeSpotlightSearch() {
|
||||
// Initialize Spotlight search indexing when the app starts
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
SpotlightSearchManager.shared.initializeSpotlightIndexing()
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually refresh Spotlight search index
|
||||
func refreshSpotlightIndex() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
SpotlightSearchManager.shared.initializeSpotlightIndexing()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check Spotlight search status for debugging
|
||||
func checkSpotlightStatus(completion: @escaping (Int) -> Void) {
|
||||
SpotlightSearchManager.shared.getIndexedItemsStatus(completion: completion)
|
||||
}
|
||||
|
||||
func fetchLinks() {
|
||||
isLoading = true
|
||||
let context = persistenceController.container.viewContext
|
||||
let request: NSFetchRequest<Link> = Link.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Link.alias, ascending: true)]
|
||||
|
||||
do {
|
||||
links = try context.fetch(request)
|
||||
filterLinks()
|
||||
isLoading = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func createLink(alias: String, originalURL: String?, text: String?, linkType: LinkType, description: String?, tags: [Tag]) -> Bool {
|
||||
let context = persistenceController.container.viewContext
|
||||
|
||||
// Check if alias already exists
|
||||
if aliasExists(alias) {
|
||||
errorMessage = NSLocalizedString("alias_already_exists", comment: "Alias already exists")
|
||||
return false
|
||||
}
|
||||
|
||||
let newLink = Link(context: context)
|
||||
newLink.alias = alias
|
||||
newLink.originalURL = originalURL
|
||||
newLink.text = text
|
||||
newLink.linkType = linkType.rawValue
|
||||
newLink.linkDescription = description
|
||||
newLink.clickCount = 0
|
||||
newLink.createdAt = Date()
|
||||
newLink.updatedAt = Date()
|
||||
|
||||
// Add tags
|
||||
for tag in tags {
|
||||
newLink.addToTags(tag)
|
||||
}
|
||||
|
||||
// Create change log
|
||||
let changeLog = ChangeLog(context: context)
|
||||
changeLog.changeType = "CREATE"
|
||||
changeLog.changeDescription = "Link created"
|
||||
changeLog.changedAt = Date()
|
||||
changeLog.link = newLink
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
||||
// Index the new link in Spotlight search
|
||||
SpotlightSearchManager.shared.indexLink(newLink)
|
||||
|
||||
fetchLinks()
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func updateLink(_ link: Link, alias: String, originalURL: String?, text: String?, linkType: LinkType, description: String?, tags: [Tag]) -> Bool {
|
||||
let context = persistenceController.container.viewContext
|
||||
|
||||
// Check if alias already exists (excluding current link)
|
||||
if alias != link.alias && aliasExists(alias) {
|
||||
errorMessage = NSLocalizedString("alias_already_exists", comment: "Alias already exists")
|
||||
return false
|
||||
}
|
||||
|
||||
link.alias = alias
|
||||
link.originalURL = originalURL
|
||||
link.text = text
|
||||
link.linkType = linkType.rawValue
|
||||
link.linkDescription = description
|
||||
link.updatedAt = Date()
|
||||
|
||||
// Update tags
|
||||
link.removeFromTags(link.tags ?? NSSet())
|
||||
for tag in tags {
|
||||
link.addToTags(tag)
|
||||
}
|
||||
|
||||
// Create change log
|
||||
let changeLog = ChangeLog(context: context)
|
||||
changeLog.changeType = "UPDATE"
|
||||
changeLog.changeDescription = "Link updated"
|
||||
changeLog.changedAt = Date()
|
||||
changeLog.link = link
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
||||
// Re-index the updated link in Spotlight search
|
||||
SpotlightSearchManager.shared.indexLink(link)
|
||||
|
||||
fetchLinks()
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deleteLink(_ link: Link) {
|
||||
let context = persistenceController.container.viewContext
|
||||
let aliasToRemove = link.alias
|
||||
|
||||
context.delete(link)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
||||
// Remove the link from Spotlight search index
|
||||
SpotlightSearchManager.shared.removeLink(withAlias: aliasToRemove)
|
||||
|
||||
fetchLinks()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func recordClick(for link: Link) {
|
||||
let context = persistenceController.container.viewContext
|
||||
|
||||
// Create click log
|
||||
let clickLog = ClickLog(context: context)
|
||||
clickLog.clickedAt = Date()
|
||||
clickLog.link = link
|
||||
|
||||
// Increment click count
|
||||
link.clickCount += 1
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
fetchLinks()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func aliasExists(_ alias: String) -> Bool {
|
||||
let context = persistenceController.container.viewContext
|
||||
let request: NSFetchRequest<Link> = Link.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "alias == %@", alias)
|
||||
request.fetchLimit = 1
|
||||
|
||||
do {
|
||||
let count = try context.count(for: request)
|
||||
return count > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func filterLinks() {
|
||||
var filtered = links
|
||||
|
||||
// Filter by search text
|
||||
if !searchText.isEmpty {
|
||||
filtered = filtered.filter { link in
|
||||
link.alias.localizedCaseInsensitiveContains(searchText) ||
|
||||
(link.originalURL?.localizedCaseInsensitiveContains(searchText) ?? false) ||
|
||||
(link.linkDescription?.localizedCaseInsensitiveContains(searchText) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by selected tag
|
||||
if let selectedTag = selectedTag {
|
||||
filtered = filtered.filter { link in
|
||||
link.sortedTags.contains(selectedTag)
|
||||
}
|
||||
}
|
||||
|
||||
filteredLinks = filtered
|
||||
sortLinks()
|
||||
}
|
||||
|
||||
private func sortLinks() {
|
||||
switch selectedSortOption {
|
||||
case .alias:
|
||||
filteredLinks.sort { $0.alias < $1.alias }
|
||||
case .type:
|
||||
filteredLinks.sort { $0.linkType < $1.linkType }
|
||||
case .clicks:
|
||||
filteredLinks.sort { $0.clickCount > $1.clickCount }
|
||||
case .dateCreated:
|
||||
filteredLinks.sort { $0.createdAt > $1.createdAt }
|
||||
case .dateUpdated:
|
||||
filteredLinks.sort { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
}
|
||||
|
||||
func openLink(_ link: Link, with parameters: [String: String] = [:]) {
|
||||
recordClick(for: link)
|
||||
|
||||
guard let urlString = link.originalURL else { return }
|
||||
|
||||
var processedURL = urlString
|
||||
|
||||
// Process template parameters
|
||||
if link.isTemplate {
|
||||
processedURL = URLTemplateProcessor.processTemplate(urlString, with: parameters)
|
||||
}
|
||||
|
||||
guard let url = URL(string: processedURL) else { return }
|
||||
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search functionality
|
||||
extension LinkViewModel {
|
||||
func performQuickSearch(_ query: String) {
|
||||
searchText = query
|
||||
|
||||
// If user presses enter with search text and no results, open as URL
|
||||
if filteredLinks.isEmpty && !query.isEmpty {
|
||||
if let url = URL(string: query.hasPrefix("http") ? query : "https://\(query)") {
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import UIKit for UIApplication
|
||||
import UIKit
|
||||
@@ -0,0 +1,476 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct AnalyticsView: View {
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@State private var selectedTimeRange: TimeRange = .oneMonth
|
||||
@State private var selectedMetric: Metric = .clicks
|
||||
@State private var showingExport = false
|
||||
|
||||
enum TimeRange: String, CaseIterable {
|
||||
case oneWeek = "1w"
|
||||
case oneMonth = "1m"
|
||||
case threeMonths = "3m"
|
||||
case sixMonths = "6m"
|
||||
case oneYear = "1y"
|
||||
case allTime = "all"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .oneWeek:
|
||||
return NSLocalizedString("1_week", comment: "1 week")
|
||||
case .oneMonth:
|
||||
return NSLocalizedString("1_month", comment: "1 month")
|
||||
case .threeMonths:
|
||||
return NSLocalizedString("3_months", comment: "3 months")
|
||||
case .sixMonths:
|
||||
return NSLocalizedString("6_months", comment: "6 months")
|
||||
case .oneYear:
|
||||
return NSLocalizedString("1_year", comment: "1 year")
|
||||
case .allTime:
|
||||
return NSLocalizedString("all_time", comment: "All time")
|
||||
}
|
||||
}
|
||||
|
||||
var dateRange: DateInterval? {
|
||||
let calendar = Calendar.current
|
||||
let endDate = Date()
|
||||
|
||||
switch self {
|
||||
case .oneWeek:
|
||||
let startDate = calendar.date(byAdding: .weekOfYear, value: -1, to: endDate)!
|
||||
return DateInterval(start: startDate, end: endDate)
|
||||
case .oneMonth:
|
||||
let startDate = calendar.date(byAdding: .month, value: -1, to: endDate)!
|
||||
return DateInterval(start: startDate, end: endDate)
|
||||
case .threeMonths:
|
||||
let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
|
||||
return DateInterval(start: startDate, end: endDate)
|
||||
case .sixMonths:
|
||||
let startDate = calendar.date(byAdding: .month, value: -6, to: endDate)!
|
||||
return DateInterval(start: startDate, end: endDate)
|
||||
case .oneYear:
|
||||
let startDate = calendar.date(byAdding: .year, value: -1, to: endDate)!
|
||||
return DateInterval(start: startDate, end: endDate)
|
||||
case .allTime:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Metric: String, CaseIterable {
|
||||
case clicks = "clicks"
|
||||
case links = "links"
|
||||
case topLinks = "top_links"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .clicks:
|
||||
return NSLocalizedString("clicks_over_time", comment: "Clicks over time")
|
||||
case .links:
|
||||
return NSLocalizedString("links_created", comment: "Links created")
|
||||
case .topLinks:
|
||||
return NSLocalizedString("top_links", comment: "Top links")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var analyticsData: AnalyticsData {
|
||||
return AnalyticsCalculator.calculateAnalytics(
|
||||
for: linkViewModel.links,
|
||||
timeRange: selectedTimeRange,
|
||||
metric: selectedMetric
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
timeRangePicker
|
||||
overviewCards
|
||||
metricSelector
|
||||
mainChart
|
||||
topLinksSection
|
||||
linkTypeDistributionSection
|
||||
recentActivitySection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("analytics")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
analyticsToolbar
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingExport) {
|
||||
ExportView(analyticsData: analyticsData)
|
||||
}
|
||||
}
|
||||
|
||||
private var timeRangePicker: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("time_range")
|
||||
.font(.headline)
|
||||
|
||||
Picker("time_range", selection: $selectedTimeRange) {
|
||||
ForEach(TimeRange.allCases, id: \.self) { range in
|
||||
Text(range.displayName).tag(range)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewCards: some View {
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: 16) {
|
||||
OverviewCard(
|
||||
title: "total_links",
|
||||
value: "\(analyticsData.totalLinks)",
|
||||
change: analyticsData.linksChange,
|
||||
icon: "link"
|
||||
)
|
||||
|
||||
OverviewCard(
|
||||
title: "total_clicks",
|
||||
value: "\(analyticsData.totalClicks)",
|
||||
change: analyticsData.clicksChange,
|
||||
icon: "hand.tap"
|
||||
)
|
||||
|
||||
OverviewCard(
|
||||
title: "avg_clicks_per_link",
|
||||
value: String(format: "%.1f", analyticsData.averageClicksPerLink),
|
||||
change: analyticsData.avgClicksChange,
|
||||
icon: "chart.bar"
|
||||
)
|
||||
|
||||
OverviewCard(
|
||||
title: "active_links",
|
||||
value: "\(analyticsData.activeLinks)",
|
||||
change: analyticsData.activeLinksChange,
|
||||
icon: "bolt"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var metricSelector: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("metric")
|
||||
.font(.headline)
|
||||
|
||||
Picker("metric", selection: $selectedMetric) {
|
||||
ForEach(Metric.allCases, id: \.self) { metric in
|
||||
Text(metric.displayName).tag(metric)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
}
|
||||
|
||||
private var mainChart: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(selectedMetric.displayName)
|
||||
.font(.headline)
|
||||
|
||||
if analyticsData.chartData.isEmpty {
|
||||
Text("no_data_available")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
} else {
|
||||
Chart(analyticsData.chartData) { dataPoint in
|
||||
switch selectedMetric {
|
||||
case .clicks:
|
||||
LineMark(
|
||||
x: .value("Date", dataPoint.date),
|
||||
y: .value("Clicks", dataPoint.count)
|
||||
)
|
||||
.foregroundStyle(.blue)
|
||||
case .links:
|
||||
BarMark(
|
||||
x: .value("Date", dataPoint.date),
|
||||
y: .value("Links", dataPoint.count)
|
||||
)
|
||||
.foregroundStyle(.green)
|
||||
case .topLinks:
|
||||
BarMark(
|
||||
x: .value("Link", dataPoint.label),
|
||||
y: .value("Clicks", dataPoint.count)
|
||||
)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
.chartXAxisLabel(selectedMetric == .topLinks ? "Links" : "Date")
|
||||
.chartYAxisLabel(selectedMetric == .links ? "Links Created" : "Clicks")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var topLinksSection: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("top_performing_links")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(analyticsData.topLinks, id: \.link.id) { linkData in
|
||||
TopLinkRow(linkData: linkData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var linkTypeDistributionSection: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("link_type_distribution")
|
||||
.font(.headline)
|
||||
|
||||
if !analyticsData.linkTypeDistribution.isEmpty {
|
||||
Chart(analyticsData.linkTypeDistribution, id: \.type) { data in
|
||||
SectorMark(
|
||||
angle: .value("Count", data.count),
|
||||
innerRadius: .ratio(0.5),
|
||||
angularInset: 2
|
||||
)
|
||||
.foregroundStyle(linkTypeColor(data.type))
|
||||
}
|
||||
.frame(height: 200)
|
||||
}
|
||||
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: 12) {
|
||||
ForEach(analyticsData.linkTypeDistribution, id: \.type) { data in
|
||||
VStack {
|
||||
Text(data.type.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(data.count)")
|
||||
.font(.headline)
|
||||
.foregroundColor(linkTypeColor(data.type))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var recentActivitySection: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("recent_activity")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(analyticsData.recentActivity.prefix(10), id: \.id) { activity in
|
||||
RecentActivityRow(activity: activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var analyticsToolbar: some View {
|
||||
Menu {
|
||||
Button("export_data") {
|
||||
showingExport = true
|
||||
}
|
||||
|
||||
Button("refresh_data") {
|
||||
linkViewModel.fetchLinks()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
|
||||
private func linkTypeColor(_ linkType: LinkType) -> Color {
|
||||
switch linkType {
|
||||
case .link:
|
||||
return .blue
|
||||
case .custom:
|
||||
return .green
|
||||
case .template:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OverviewCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let change: Double?
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.blue)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(value)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
if let change = change {
|
||||
HStack {
|
||||
Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
Text(String(format: "%.1f%%", abs(change)))
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(change >= 0 ? .green : .red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
|
||||
struct TopLinkRow: View {
|
||||
let linkData: TopLinkData
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(linkData.link.alias)
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
|
||||
if let url = linkData.link.originalURL {
|
||||
Text(url)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
Text("\(linkData.clicks)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text("clicks")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentActivityRow: View {
|
||||
let activity: RecentActivity
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: activity.type.icon)
|
||||
.foregroundColor(activity.type.color)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(activity.description)
|
||||
.font(.body)
|
||||
Text(activity.timestamp, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct ExportView: View {
|
||||
let analyticsData: AnalyticsData
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@State private var exportFormat: ExportFormat = .json
|
||||
@State private var includeChartData = true
|
||||
@State private var includeTopLinks = true
|
||||
@State private var isExporting = false
|
||||
|
||||
enum ExportFormat: String, CaseIterable {
|
||||
case json = "JSON"
|
||||
case csv = "CSV"
|
||||
|
||||
var displayName: String {
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("export_options") {
|
||||
Picker("format", selection: $exportFormat) {
|
||||
ForEach(ExportFormat.allCases, id: \.self) { format in
|
||||
Text(format.displayName).tag(format)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("include_chart_data", isOn: $includeChartData)
|
||||
Toggle("include_top_links", isOn: $includeTopLinks)
|
||||
}
|
||||
|
||||
Section("preview") {
|
||||
Text("total_links: \(analyticsData.totalLinks)")
|
||||
Text("total_clicks: \(analyticsData.totalClicks)")
|
||||
Text("chart_data_points: \(analyticsData.chartData.count)")
|
||||
Text("top_links: \(analyticsData.topLinks.count)")
|
||||
}
|
||||
}
|
||||
.navigationTitle("export_analytics")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("export") {
|
||||
exportData()
|
||||
}
|
||||
.disabled(isExporting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportData() {
|
||||
isExporting = true
|
||||
|
||||
// Implementation would depend on the specific export requirements
|
||||
// For now, we'll just simulate the export
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
isExporting = false
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AnalyticsView()
|
||||
.environmentObject(LinkViewModel())
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct LinkDetailView: View {
|
||||
let link: Link
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@State private var selectedTimeRange: TimeRange = .threeMonths
|
||||
@State private var showingEdit = false
|
||||
@State private var templateParameters: [String: String] = [:]
|
||||
@State private var showingTemplateDialog = false
|
||||
|
||||
enum TimeRange: String, CaseIterable {
|
||||
case threeMonths = "3m"
|
||||
case sixMonths = "6m"
|
||||
case oneYear = "1y"
|
||||
case allTime = "all"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .threeMonths:
|
||||
return NSLocalizedString("3_months", comment: "3 months")
|
||||
case .sixMonths:
|
||||
return NSLocalizedString("6_months", comment: "6 months")
|
||||
case .oneYear:
|
||||
return NSLocalizedString("1_year", comment: "1 year")
|
||||
case .allTime:
|
||||
return NSLocalizedString("all_time", comment: "All time")
|
||||
}
|
||||
}
|
||||
|
||||
var dateRange: DateInterval? {
|
||||
let calendar = Calendar.current
|
||||
let endDate = Date()
|
||||
|
||||
switch self {
|
||||
case .threeMonths:
|
||||
let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
|
||||
return DateInterval(start: startDate, end: endDate)
|
||||
case .sixMonths:
|
||||
let startDate = calendar.date(byAdding: .month, value: -6, to: endDate)!
|
||||
return DateInterval(start: startDate, end: endDate)
|
||||
case .oneYear:
|
||||
let startDate = calendar.date(byAdding: .year, value: -1, to: endDate)!
|
||||
return DateInterval(start: startDate, end: endDate)
|
||||
case .allTime:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var filteredClickLogs: [ClickLog] {
|
||||
let logs = link.sortedClickLogs
|
||||
|
||||
guard let dateRange = selectedTimeRange.dateRange else {
|
||||
return logs
|
||||
}
|
||||
|
||||
return logs.filter { log in
|
||||
dateRange.contains(log.clickedAt)
|
||||
}
|
||||
}
|
||||
|
||||
var chartData: [ChartDataPoint] {
|
||||
let calendar = Calendar.current
|
||||
let logs = filteredClickLogs
|
||||
|
||||
guard !logs.isEmpty else { return [] }
|
||||
|
||||
// Determine the grouping based on time range
|
||||
let component: Calendar.Component
|
||||
let dateFormat: String
|
||||
|
||||
switch selectedTimeRange {
|
||||
case .threeMonths, .sixMonths:
|
||||
component = .day
|
||||
dateFormat = "MMM d"
|
||||
case .oneYear:
|
||||
component = .weekOfYear
|
||||
dateFormat = "MMM d"
|
||||
case .allTime:
|
||||
component = .month
|
||||
dateFormat = "MMM yyyy"
|
||||
}
|
||||
|
||||
// Group clicks by the component
|
||||
let groupedLogs = Dictionary(grouping: logs) { log in
|
||||
calendar.dateInterval(of: component, for: log.clickedAt)?.start ?? log.clickedAt
|
||||
}
|
||||
|
||||
// Create chart data points
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = dateFormat
|
||||
|
||||
return groupedLogs.map { date, logs in
|
||||
ChartDataPoint(
|
||||
date: date,
|
||||
count: logs.count,
|
||||
label: formatter.string(from: date)
|
||||
)
|
||||
}.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
headerSection
|
||||
tagsSection
|
||||
analyticsSection
|
||||
customContentSection
|
||||
changeHistorySection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("link_details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("close") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
toolbarMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingEdit) {
|
||||
LinkFormView(link: link)
|
||||
.environmentObject(linkViewModel)
|
||||
}
|
||||
.sheet(isPresented: $showingTemplateDialog) {
|
||||
TemplateParameterView(
|
||||
link: link,
|
||||
parameters: $templateParameters,
|
||||
onOpen: { params in
|
||||
linkViewModel.openLink(link, with: params)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(link.alias)
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(link.linkTypeEnum.displayName)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(linkTypeColor(link.linkTypeEnum))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
if let url = link.originalURL {
|
||||
Text(url)
|
||||
.font(.body)
|
||||
.foregroundColor(.blue)
|
||||
.lineLimit(nil)
|
||||
}
|
||||
|
||||
if let description = link.linkDescription {
|
||||
Text(description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private var tagsSection: some View {
|
||||
Group {
|
||||
if !link.sortedTags.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
Text("tags")
|
||||
.font(.headline)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(link.sortedTags, id: \.id) { tag in
|
||||
NavigationLink(destination: TagDetailView(tag: tag)) {
|
||||
Text(tag.name)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.2))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var analyticsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("analytics")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("time_range", selection: $selectedTimeRange) {
|
||||
ForEach(TimeRange.allCases, id: \.self) { range in
|
||||
Text(range.displayName).tag(range)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
|
||||
analyticsStatsCards
|
||||
analyticsChart
|
||||
}
|
||||
}
|
||||
|
||||
private var analyticsStatsCards: some View {
|
||||
HStack(spacing: 16) {
|
||||
StatCard(
|
||||
title: "total_clicks",
|
||||
value: "\(link.clickCount)",
|
||||
icon: "hand.tap"
|
||||
)
|
||||
|
||||
StatCard(
|
||||
title: "clicks_in_period",
|
||||
value: "\(filteredClickLogs.count)",
|
||||
icon: "chart.bar"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var analyticsChart: some View {
|
||||
Group {
|
||||
if !chartData.isEmpty {
|
||||
Chart(chartData) { dataPoint in
|
||||
BarMark(
|
||||
x: .value("Date", dataPoint.date),
|
||||
y: .value("Clicks", dataPoint.count)
|
||||
)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.frame(height: 200)
|
||||
.chartXAxisLabel("Date")
|
||||
.chartYAxisLabel("Clicks")
|
||||
} else {
|
||||
Text("no_data_for_period")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customContentSection: some View {
|
||||
Group {
|
||||
if link.linkTypeEnum == .custom, let text = link.text {
|
||||
VStack(alignment: .leading) {
|
||||
Text("content_preview")
|
||||
.font(.headline)
|
||||
|
||||
MarkdownView(text: text)
|
||||
.frame(maxHeight: 300)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var changeHistorySection: some View {
|
||||
Group {
|
||||
if ((link.changeLogs?.allObjects.isEmpty) == nil) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("change_history")
|
||||
.font(.headline)
|
||||
|
||||
let changeLogs = (link.changeLogs?.allObjects as? [ChangeLog] ?? [])
|
||||
.sorted { $0.changedAt > $1.changedAt }
|
||||
|
||||
ForEach(changeLogs.prefix(10), id: \.id) { changeLog in
|
||||
HStack {
|
||||
Image(systemName: changeLog.changeType == "CREATE" ? "plus.circle" : "pencil.circle")
|
||||
.foregroundColor(changeLog.changeType == "CREATE" ? .green : .orange)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(changeLog.changeDescription)
|
||||
.font(.body)
|
||||
Text(changeLog.changedAt, format: .dateTime)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbarMenu: some View {
|
||||
Menu {
|
||||
Button("edit") {
|
||||
showingEdit = true
|
||||
}
|
||||
|
||||
if link.isTemplate {
|
||||
Button("open_with_parameters") {
|
||||
showingTemplateDialog = true
|
||||
}
|
||||
} else {
|
||||
Button("open_link") {
|
||||
linkViewModel.openLink(link)
|
||||
}
|
||||
}
|
||||
|
||||
Button("delete", role: .destructive) {
|
||||
linkViewModel.deleteLink(link)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
|
||||
private func linkTypeColor(_ linkType: LinkType) -> Color {
|
||||
switch linkType {
|
||||
case .link:
|
||||
return .blue
|
||||
case .custom:
|
||||
return .green
|
||||
case .template:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.blue)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(value)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChartDataPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
let count: Int
|
||||
let label: String
|
||||
}
|
||||
|
||||
struct TemplateParameterView: View {
|
||||
let link: Link
|
||||
@Binding var parameters: [String: String]
|
||||
let onOpen: ([String: String]) -> Void
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@State private var extractedParams: [TemplateParameter] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("template_parameters") {
|
||||
ForEach(extractedParams, id: \.name) { param in
|
||||
VStack(alignment: .leading) {
|
||||
Text(param.name)
|
||||
.font(.headline)
|
||||
|
||||
TextField(param.defaultValue ?? "enter_value", text: Binding(
|
||||
get: { parameters[param.name] ?? param.defaultValue ?? "" },
|
||||
set: { parameters[param.name] = $0 }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("enter_parameters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("open") {
|
||||
onOpen(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
extractTemplateParameters()
|
||||
}
|
||||
}
|
||||
|
||||
private func extractTemplateParameters() {
|
||||
guard let url = link.originalURL else { return }
|
||||
extractedParams = URLTemplateProcessor.extractParameters(from: url)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LinkDetailView(link: PersistenceController.preview.container.viewContext.registeredObjects.first { $0 is Link } as! Link)
|
||||
.environmentObject(LinkViewModel())
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LinkFormView: View {
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
let link: Link?
|
||||
|
||||
@State private var alias: String = ""
|
||||
@State private var originalURL: String = ""
|
||||
@State private var text: String = ""
|
||||
@State private var linkDescription: String = ""
|
||||
@State private var selectedLinkType: LinkType = .link
|
||||
@State private var selectedTags: [Tag] = []
|
||||
@State private var tagSearchText: String = ""
|
||||
@State private var isValidatingAlias: Bool = false
|
||||
@State private var aliasValidationMessage: String = ""
|
||||
@State private var urlValidationMessage: String = ""
|
||||
@State private var showingTagPicker: Bool = false
|
||||
@State private var saveErrorMessage: String = ""
|
||||
@State private var showingSaveError: Bool = false
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
|
||||
animation: .default)
|
||||
private var allTags: FetchedResults<Tag>
|
||||
|
||||
var isEditing: Bool {
|
||||
return link != nil
|
||||
}
|
||||
|
||||
var filteredTags: [Tag] {
|
||||
if tagSearchText.isEmpty {
|
||||
return Array(allTags)
|
||||
} else {
|
||||
return allTags.filter { $0.name.localizedCaseInsensitiveContains(tagSearchText) }
|
||||
}
|
||||
}
|
||||
|
||||
var canSave: Bool {
|
||||
return !alias.isEmpty &&
|
||||
(selectedLinkType == .custom ? !text.isEmpty : !originalURL.isEmpty) &&
|
||||
aliasValidationMessage.isEmpty &&
|
||||
urlValidationMessage.isEmpty
|
||||
}
|
||||
|
||||
var templateValidationView: some View {
|
||||
Group {
|
||||
if selectedLinkType == .template && !originalURL.isEmpty {
|
||||
let isValidTemplate = URLTemplateProcessor.validateTemplate(originalURL)
|
||||
let hasParameters = URLTemplateProcessor.hasParameters(originalURL)
|
||||
|
||||
if isValidTemplate && hasParameters {
|
||||
// Valid template with parameters - show success with iOS blue
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
Text("Valid template with parameters")
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
} else if !hasParameters {
|
||||
// No parameters detected - show warning
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text("Template should contain parameters like {query} or {query,default=value}")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
} else {
|
||||
// Invalid template - show error
|
||||
HStack {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text("Invalid template syntax. Use {parameter} or {parameter,default=value}")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("basic_information") {
|
||||
// Alias field
|
||||
VStack(alignment: .leading) {
|
||||
TextField("alias", text: $alias)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: alias) { _, newValue in
|
||||
validateAlias(newValue)
|
||||
}
|
||||
|
||||
if !aliasValidationMessage.isEmpty {
|
||||
Text(aliasValidationMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
// Link Type Picker
|
||||
Picker("link_type", selection: $selectedLinkType) {
|
||||
ForEach(LinkType.allCases, id: \.self) { linkType in
|
||||
Text(linkType.displayName).tag(linkType)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.onChange(of: selectedLinkType) { _, newValue in
|
||||
// Re-validate URL when link type changes
|
||||
if newValue != .custom {
|
||||
validateURL(originalURL)
|
||||
} else {
|
||||
urlValidationMessage = ""
|
||||
}
|
||||
}
|
||||
|
||||
// URL or Text field based on type
|
||||
if selectedLinkType == .custom {
|
||||
VStack(alignment: .leading) {
|
||||
Text("markdown_content")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
TextEditor(text: $text)
|
||||
.frame(minHeight: 100)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
TextField("original_url", text: $originalURL)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(getBorderColor(), lineWidth: 2)
|
||||
)
|
||||
.onChange(of: originalURL) { _, newValue in
|
||||
validateURL(newValue)
|
||||
}
|
||||
|
||||
if !urlValidationMessage.isEmpty {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.orange)
|
||||
Text(urlValidationMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if selectedLinkType == .template {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Template help text
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Template type allows you to create dynamic URLs with parameters. Use {query, default=value} syntax in the URL.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Example: https://google.com/search?q={query, default=hello}")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
// Template validation status
|
||||
if !originalURL.isEmpty {
|
||||
templateValidationView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description field
|
||||
TextField("description_optional", text: $linkDescription)
|
||||
}
|
||||
|
||||
Section("tags") {
|
||||
// Selected tags
|
||||
if !selectedTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(selectedTags, id: \.id) { tag in
|
||||
TagChip(tag: tag, onRemove: {
|
||||
selectedTags.removeAll { $0.id == tag.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// Tag search and selection
|
||||
HStack {
|
||||
TextField("search_or_create_tag", text: $tagSearchText)
|
||||
|
||||
Button("add") {
|
||||
addOrCreateTag()
|
||||
}
|
||||
.disabled(tagSearchText.isEmpty)
|
||||
}
|
||||
|
||||
// Filtered tags list
|
||||
ForEach(filteredTags.prefix(5), id: \.id) { tag in
|
||||
HStack {
|
||||
Text(tag.name)
|
||||
Spacer()
|
||||
if selectedTags.contains(where: { $0.id == tag.id }) {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
toggleTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isEditing {
|
||||
Section("actions") {
|
||||
Button("delete_link", role: .destructive) {
|
||||
if let link = link {
|
||||
linkViewModel.deleteLink(link)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "edit_link" : "add_link")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("save") {
|
||||
saveLink()
|
||||
}
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setupForm()
|
||||
}
|
||||
.alert("Error", isPresented: $showingSaveError) {
|
||||
Button("OK") {
|
||||
showingSaveError = false
|
||||
saveErrorMessage = ""
|
||||
}
|
||||
} message: {
|
||||
Text(saveErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupForm() {
|
||||
if let link = link {
|
||||
alias = link.alias
|
||||
originalURL = link.originalURL ?? ""
|
||||
text = link.text ?? ""
|
||||
linkDescription = link.linkDescription ?? ""
|
||||
selectedLinkType = link.linkTypeEnum
|
||||
selectedTags = link.sortedTags
|
||||
|
||||
// Validate existing data
|
||||
validateAlias(alias)
|
||||
validateURL(originalURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateAlias(_ newAlias: String) {
|
||||
isValidatingAlias = true
|
||||
|
||||
// Basic validation
|
||||
if newAlias.isEmpty {
|
||||
aliasValidationMessage = ""
|
||||
isValidatingAlias = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check format (alphanumeric and some special characters)
|
||||
let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
if newAlias.unicodeScalars.contains(where: { !allowedCharacters.contains($0) }) {
|
||||
aliasValidationMessage = NSLocalizedString("alias_invalid_characters", comment: "Alias contains invalid characters")
|
||||
isValidatingAlias = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check if alias exists (excluding current link if editing)
|
||||
if !isEditing || (isEditing && newAlias != link?.alias) {
|
||||
if linkViewModel.aliasExists(newAlias) {
|
||||
aliasValidationMessage = NSLocalizedString("alias_already_exists", comment: "Alias already exists")
|
||||
} else {
|
||||
aliasValidationMessage = ""
|
||||
}
|
||||
} else {
|
||||
aliasValidationMessage = ""
|
||||
}
|
||||
|
||||
isValidatingAlias = false
|
||||
}
|
||||
|
||||
private func validateURL(_ url: String) {
|
||||
// Don't validate empty URLs (allow empty for templates with placeholders)
|
||||
if url.isEmpty {
|
||||
urlValidationMessage = ""
|
||||
return
|
||||
}
|
||||
|
||||
// For templates, use URLTemplateProcessor for validation
|
||||
if selectedLinkType == .template {
|
||||
// Check if it's a valid template
|
||||
if URLTemplateProcessor.validateTemplate(url) {
|
||||
// Check if the base URL (without parameters) is valid
|
||||
let urlWithoutPlaceholders = url.replacingOccurrences(of: #"\{[^}]+\}"#, with: "placeholder", options: .regularExpression)
|
||||
if isValidURL(urlWithoutPlaceholders) {
|
||||
urlValidationMessage = ""
|
||||
return
|
||||
} else {
|
||||
urlValidationMessage = NSLocalizedString("invalid_base_url_format", comment: "Invalid base URL format")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
urlValidationMessage = NSLocalizedString("invalid_template_format", comment: "Invalid template format")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For regular links, validate the URL strictly
|
||||
if selectedLinkType == .link {
|
||||
if isValidURL(url) {
|
||||
urlValidationMessage = ""
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, the URL is invalid
|
||||
urlValidationMessage = NSLocalizedString("invalid_url_format", comment: "Invalid URL format")
|
||||
}
|
||||
|
||||
private func isValidURL(_ urlString: String) -> Bool {
|
||||
// First check if it's a valid URL
|
||||
guard let url = URL(string: urlString) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it has a scheme
|
||||
guard let scheme = url.scheme else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow http, https, and other common schemes
|
||||
let allowedSchemes = ["http", "https", "ftp", "mailto", "tel", "sms"]
|
||||
if !allowedSchemes.contains(scheme.lowercased()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it has a host for http/https URLs
|
||||
if ["http", "https"].contains(scheme.lowercased()) {
|
||||
guard let host = url.host, !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func getBorderColor() -> Color {
|
||||
// Show error state if there's a validation message
|
||||
if !urlValidationMessage.isEmpty {
|
||||
return .orange
|
||||
}
|
||||
|
||||
// For template links, show blue if valid template with parameters
|
||||
if selectedLinkType == .template && !originalURL.isEmpty {
|
||||
let isValidTemplate = URLTemplateProcessor.validateTemplate(originalURL)
|
||||
let hasParameters = URLTemplateProcessor.hasParameters(originalURL)
|
||||
|
||||
if isValidTemplate && hasParameters {
|
||||
return .blue
|
||||
}
|
||||
}
|
||||
|
||||
return .clear
|
||||
}
|
||||
|
||||
private func toggleTag(_ tag: Tag) {
|
||||
if selectedTags.contains(where: { $0.id == tag.id }) {
|
||||
selectedTags.removeAll { $0.id == tag.id }
|
||||
} else {
|
||||
selectedTags.append(tag)
|
||||
}
|
||||
}
|
||||
|
||||
private func addOrCreateTag() {
|
||||
let trimmedText = tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedText.isEmpty else { return }
|
||||
|
||||
// Check if tag already exists
|
||||
if let existingTag = allTags.first(where: { $0.name.lowercased() == trimmedText.lowercased() }) {
|
||||
if !selectedTags.contains(where: { $0.id == existingTag.id }) {
|
||||
selectedTags.append(existingTag)
|
||||
}
|
||||
} else {
|
||||
// Create new tag
|
||||
let newTag = Tag(context: viewContext)
|
||||
newTag.name = trimmedText
|
||||
newTag.slug = trimmedText.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||
newTag.createdAt = Date()
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
selectedTags.append(newTag)
|
||||
} catch {
|
||||
print("Error creating tag: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
tagSearchText = ""
|
||||
}
|
||||
|
||||
private func saveLink() {
|
||||
// Clear any previous error
|
||||
saveErrorMessage = ""
|
||||
showingSaveError = false
|
||||
|
||||
// Validate before saving
|
||||
if !aliasValidationMessage.isEmpty {
|
||||
saveErrorMessage = aliasValidationMessage
|
||||
showingSaveError = true
|
||||
return
|
||||
}
|
||||
|
||||
if !urlValidationMessage.isEmpty && selectedLinkType != .custom {
|
||||
saveErrorMessage = urlValidationMessage
|
||||
showingSaveError = true
|
||||
return
|
||||
}
|
||||
|
||||
let success: Bool
|
||||
|
||||
if isEditing, let link = link {
|
||||
success = linkViewModel.updateLink(
|
||||
link,
|
||||
alias: alias,
|
||||
originalURL: selectedLinkType == .custom ? nil : originalURL,
|
||||
text: selectedLinkType == .custom ? text : nil,
|
||||
linkType: selectedLinkType,
|
||||
description: linkDescription.isEmpty ? nil : linkDescription,
|
||||
tags: selectedTags
|
||||
)
|
||||
} else {
|
||||
success = linkViewModel.createLink(
|
||||
alias: alias,
|
||||
originalURL: selectedLinkType == .custom ? nil : originalURL,
|
||||
text: selectedLinkType == .custom ? text : nil,
|
||||
linkType: selectedLinkType,
|
||||
description: linkDescription.isEmpty ? nil : linkDescription,
|
||||
tags: selectedTags
|
||||
)
|
||||
}
|
||||
|
||||
if success {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} else {
|
||||
// Show error from view model
|
||||
if let errorMessage = linkViewModel.errorMessage {
|
||||
saveErrorMessage = errorMessage
|
||||
showingSaveError = true
|
||||
linkViewModel.errorMessage = nil // Clear the error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TagChip: View {
|
||||
let tag: Tag
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(tag.name)
|
||||
.font(.caption)
|
||||
|
||||
Button(action: onRemove) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.2))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LinkFormView(link: nil)
|
||||
.environmentObject(LinkViewModel())
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LinkListView: View {
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@State private var showingAddLink = false
|
||||
@State private var showingFilterSheet = false
|
||||
@State private var selectedLink: Link?
|
||||
@State private var showingMarkdownContent = false
|
||||
@State private var markdownLink: Link?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
// Search bar
|
||||
SearchBar(text: $linkViewModel.searchText)
|
||||
.padding(.horizontal)
|
||||
|
||||
// Filter and sort bar
|
||||
HStack {
|
||||
Button(action: {
|
||||
showingFilterSheet = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "line.horizontal.3.decrease.circle")
|
||||
Text("filter")
|
||||
if linkViewModel.selectedTag != nil {
|
||||
Text("•")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
ForEach(LinkViewModel.SortOption.allCases, id: \.self) { option in
|
||||
Button(action: {
|
||||
linkViewModel.selectedSortOption = option
|
||||
}) {
|
||||
HStack {
|
||||
Text(option.displayName)
|
||||
if linkViewModel.selectedSortOption == option {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
Text(linkViewModel.selectedSortOption.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Count indicator (shows filtered count and total)
|
||||
if !linkViewModel.isLoading {
|
||||
HStack(spacing: 6) {
|
||||
let filtered = linkViewModel.filteredLinks.count
|
||||
let total = linkViewModel.links.count
|
||||
// Use monospace digits for subtle alignment
|
||||
Text("\(filtered)")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
.foregroundColor(.secondary)
|
||||
.cornerRadius(4)
|
||||
.accessibilityLabel("Filtered links count")
|
||||
if filtered != total && total > 0 {
|
||||
Text("of \(total)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.transition(.opacity)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 2)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
// Links list
|
||||
if linkViewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if linkViewModel.filteredLinks.isEmpty {
|
||||
VStack {
|
||||
Image(systemName: "link.badge.plus")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
Text("no_links_found")
|
||||
.font(.title2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("tap_plus_to_add_link")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List {
|
||||
ForEach(linkViewModel.filteredLinks) { link in
|
||||
LinkRowView(link: link) { customLink in
|
||||
markdownLink = customLink
|
||||
showingMarkdownContent = true
|
||||
}
|
||||
.onTapGesture {
|
||||
selectedLink = link
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button("delete", role: .destructive) {
|
||||
linkViewModel.deleteLink(link)
|
||||
}
|
||||
|
||||
Button("edit") {
|
||||
selectedLink = link
|
||||
showingAddLink = true
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
linkViewModel.fetchLinks()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
HStack {
|
||||
Image(systemName: "dog.fill")
|
||||
.foregroundColor(.black)
|
||||
Text("Heygo")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
selectedLink = nil
|
||||
showingAddLink = true
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddLink) {
|
||||
LinkFormView(link: selectedLink)
|
||||
.environmentObject(linkViewModel)
|
||||
}
|
||||
.sheet(isPresented: $showingFilterSheet) {
|
||||
FilterView()
|
||||
.environmentObject(linkViewModel)
|
||||
}
|
||||
.sheet(item: $selectedLink) { link in
|
||||
LinkDetailView(link: link)
|
||||
.environmentObject(linkViewModel)
|
||||
}
|
||||
.sheet(isPresented: $showingMarkdownContent) {
|
||||
if let markdownLink = markdownLink {
|
||||
MarkdownContentView(link: markdownLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if linkViewModel.links.isEmpty {
|
||||
linkViewModel.fetchLinks()
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .constant(linkViewModel.errorMessage != nil)) {
|
||||
Button("OK") {
|
||||
linkViewModel.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(linkViewModel.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkRowView: View {
|
||||
let link: Link
|
||||
let onCustomContentTap: (Link) -> Void
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Alias
|
||||
HStack {
|
||||
Text(link.alias)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Link type badge
|
||||
Text(link.linkTypeEnum.displayName)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(linkTypeColor(link.linkTypeEnum))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
// URL or description
|
||||
if let url = link.originalURL, !url.isEmpty {
|
||||
Text(url)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
} else if let text = link.text, !text.isEmpty {
|
||||
Text("custom_content")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
}
|
||||
|
||||
// Description
|
||||
if let description = link.linkDescription, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
// Tags and stats
|
||||
HStack {
|
||||
// Tags
|
||||
if !link.sortedTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(link.sortedTags, id: \.id) { tag in
|
||||
Text(tag.name)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.2))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Click count
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "hand.tap")
|
||||
Text("\(link.clickCount)")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Action button
|
||||
Button(action: {
|
||||
switch link.linkTypeEnum {
|
||||
case .custom:
|
||||
// Record click for custom links and show markdown content
|
||||
linkViewModel.recordClick(for: link)
|
||||
onCustomContentTap(link)
|
||||
case .link, .template:
|
||||
// Open URL for regular links and templates
|
||||
linkViewModel.openLink(link)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: link.isTemplate ? "arrow.right.circle.fill" : "arrow.up.right.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func linkTypeColor(_ linkType: LinkType) -> Color {
|
||||
switch linkType {
|
||||
case .link:
|
||||
return .blue
|
||||
case .custom:
|
||||
return .green
|
||||
case .template:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchBar: View {
|
||||
@Binding var text: String
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
TextField("search_or_enter_url", text: $text)
|
||||
.padding(7)
|
||||
.padding(.horizontal, 25)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.gray)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 8)
|
||||
|
||||
if isEditing {
|
||||
Button(action: {
|
||||
self.text = ""
|
||||
}) {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.gray)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
self.isEditing = true
|
||||
}
|
||||
|
||||
if isEditing {
|
||||
Button(action: {
|
||||
self.isEditing = false
|
||||
self.text = ""
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}) {
|
||||
Text("cancel")
|
||||
}
|
||||
.padding(.trailing, 10)
|
||||
.transition(.move(edge: .trailing))
|
||||
.animation(.default, value: isEditing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FilterView: View {
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
|
||||
animation: .default)
|
||||
private var tags: FetchedResults<Tag>
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section("filters") {
|
||||
HStack {
|
||||
Text("selected_tag")
|
||||
Spacer()
|
||||
if let selectedTag = linkViewModel.selectedTag {
|
||||
Text(selectedTag.name)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("all_tags")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
linkViewModel.selectedTag = nil
|
||||
}
|
||||
}
|
||||
|
||||
Section("tags") {
|
||||
ForEach(tags) { tag in
|
||||
HStack {
|
||||
Text(tag.name)
|
||||
Spacer()
|
||||
Text("\(tag.linkCount)")
|
||||
.foregroundColor(.secondary)
|
||||
if linkViewModel.selectedTag == tag {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
linkViewModel.selectedTag = tag
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("filter_links")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("done") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LinkListView()
|
||||
.environmentObject(LinkViewModel())
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
}
|
||||
|
||||
struct MarkdownContentView: View {
|
||||
let link: Link
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@State private var showingEditLink = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Alias label
|
||||
HStack {
|
||||
Text(link.alias)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.2))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(6)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Markdown content
|
||||
ScrollView {
|
||||
if let content = link.text, !content.isEmpty {
|
||||
Text(MarkdownRenderer.renderToAttributedString(content))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
VStack {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No content available")
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Custom Content")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Edit") {
|
||||
showingEditLink = true
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingEditLink) {
|
||||
LinkFormView(link: link)
|
||||
.environmentObject(linkViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchView: View {
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@State private var searchText: String = ""
|
||||
@State private var searchHistory: [String] = []
|
||||
@State private var selectedFilters: Set<LinkType> = []
|
||||
@State private var selectedTags: Set<Tag> = []
|
||||
@State private var showingAdvancedFilters = false
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
|
||||
animation: .default)
|
||||
private var allTags: FetchedResults<Tag>
|
||||
|
||||
var filteredLinks: [Link] {
|
||||
var links = linkViewModel.links
|
||||
|
||||
// Apply search text filter
|
||||
if !searchText.isEmpty {
|
||||
links = links.filter { link in
|
||||
link.alias.localizedCaseInsensitiveContains(searchText) ||
|
||||
(link.originalURL?.localizedCaseInsensitiveContains(searchText) ?? false) ||
|
||||
(link.linkDescription?.localizedCaseInsensitiveContains(searchText) ?? false) ||
|
||||
(link.text?.localizedCaseInsensitiveContains(searchText) ?? false) ||
|
||||
link.sortedTags.contains { tag in
|
||||
tag.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply type filters
|
||||
if !selectedFilters.isEmpty {
|
||||
links = links.filter { link in
|
||||
selectedFilters.contains(link.linkTypeEnum)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tag filters
|
||||
if !selectedTags.isEmpty {
|
||||
links = links.filter { link in
|
||||
let linkTags = Set(link.sortedTags)
|
||||
return !selectedTags.intersection(linkTags).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
// Search bar
|
||||
HStack {
|
||||
TextField("search_links", text: $searchText)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onSubmit {
|
||||
performSearch()
|
||||
}
|
||||
|
||||
Button("search") {
|
||||
performSearch()
|
||||
}
|
||||
.disabled(searchText.isEmpty)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Quick actions
|
||||
HStack {
|
||||
Button(action: {
|
||||
showingAdvancedFilters.toggle()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
Text("filters")
|
||||
if !selectedFilters.isEmpty || !selectedTags.isEmpty {
|
||||
Text("(\(selectedFilters.count + selectedTags.count))")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !searchText.isEmpty && filteredLinks.isEmpty {
|
||||
Button("open_as_url") {
|
||||
openSearchAsURL()
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Advanced filters (collapsible)
|
||||
if showingAdvancedFilters {
|
||||
VStack {
|
||||
// Type filters
|
||||
VStack(alignment: .leading) {
|
||||
Text("link_types")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(LinkType.allCases, id: \.self) { linkType in
|
||||
FilterChip(
|
||||
title: linkType.displayName,
|
||||
isSelected: selectedFilters.contains(linkType),
|
||||
onTap: {
|
||||
if selectedFilters.contains(linkType) {
|
||||
selectedFilters.remove(linkType)
|
||||
} else {
|
||||
selectedFilters.insert(linkType)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// Tag filters
|
||||
if !allTags.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
Text("tags")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(Array(allTags.prefix(10)), id: \.id) { tag in
|
||||
FilterChip(
|
||||
title: tag.name,
|
||||
isSelected: selectedTags.contains(tag),
|
||||
onTap: {
|
||||
if selectedTags.contains(tag) {
|
||||
selectedTags.remove(tag)
|
||||
} else {
|
||||
selectedTags.insert(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear filters button
|
||||
if !selectedFilters.isEmpty || !selectedTags.isEmpty {
|
||||
Button("clear_filters") {
|
||||
selectedFilters.removeAll()
|
||||
selectedTags.removeAll()
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
.background(Color(.systemGray6))
|
||||
}
|
||||
|
||||
// Results
|
||||
if searchText.isEmpty && selectedFilters.isEmpty && selectedTags.isEmpty {
|
||||
// Show search suggestions and history
|
||||
SearchSuggestionsView(
|
||||
searchHistory: searchHistory,
|
||||
allTags: Array(allTags),
|
||||
onSearchTap: { suggestion in
|
||||
searchText = suggestion
|
||||
performSearch()
|
||||
}
|
||||
)
|
||||
} else if filteredLinks.isEmpty {
|
||||
// No results
|
||||
VStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("no_results_found")
|
||||
.font(.title2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button("open_as_url") {
|
||||
openSearchAsURL()
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
// Results list
|
||||
List {
|
||||
ForEach(filteredLinks) { link in
|
||||
SearchResultRow(link: link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("search")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
.onAppear {
|
||||
loadSearchHistory()
|
||||
}
|
||||
}
|
||||
|
||||
private func performSearch() {
|
||||
let trimmedText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedText.isEmpty else { return }
|
||||
|
||||
// Add to search history
|
||||
if !searchHistory.contains(trimmedText) {
|
||||
searchHistory.insert(trimmedText, at: 0)
|
||||
searchHistory = Array(searchHistory.prefix(10)) // Keep only last 10
|
||||
saveSearchHistory()
|
||||
}
|
||||
|
||||
// Hide keyboard
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
|
||||
private func openSearchAsURL() {
|
||||
let urlString = searchText.hasPrefix("http") ? searchText : "https://\(searchText)"
|
||||
|
||||
if let url = URL(string: urlString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSearchHistory() {
|
||||
if let data = UserDefaults.standard.data(forKey: "searchHistory"),
|
||||
let history = try? JSONDecoder().decode([String].self, from: data) {
|
||||
searchHistory = history
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSearchHistory() {
|
||||
if let data = try? JSONEncoder().encode(searchHistory) {
|
||||
UserDefaults.standard.set(data, forKey: "searchHistory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FilterChip: View {
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? Color.blue : Color(.systemGray5))
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchSuggestionsView: View {
|
||||
let searchHistory: [String]
|
||||
let allTags: [Tag]
|
||||
let onSearchTap: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Search history
|
||||
if !searchHistory.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
Text("recent_searches")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
ForEach(searchHistory, id: \.self) { search in
|
||||
Button(action: {
|
||||
onSearchTap(search)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
.foregroundColor(.secondary)
|
||||
Text(search)
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.left")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Popular tags
|
||||
if !allTags.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
Text("popular_tags")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(allTags.prefix(10), id: \.id) { tag in
|
||||
Button(action: {
|
||||
onSearchTap(tag.name)
|
||||
}) {
|
||||
VStack {
|
||||
Text(tag.name)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Text("\(tag.linkCount)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick tips
|
||||
VStack(alignment: .leading) {
|
||||
Text("search_tips")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SearchTip(
|
||||
icon: "magnifyingglass",
|
||||
title: "search_by_alias",
|
||||
description: "search_by_alias_description"
|
||||
)
|
||||
|
||||
SearchTip(
|
||||
icon: "link",
|
||||
title: "search_by_url",
|
||||
description: "search_by_url_description"
|
||||
)
|
||||
|
||||
SearchTip(
|
||||
icon: "tag",
|
||||
title: "search_by_tag",
|
||||
description: "search_by_tag_description"
|
||||
)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchTip: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(NSLocalizedString(title, comment: ""))
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Text(NSLocalizedString(description, comment: ""))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResultRow: View {
|
||||
let link: Link
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Alias with highlighting
|
||||
Text(link.alias)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
// URL or content type
|
||||
if let url = link.originalURL {
|
||||
Text(url)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("custom_content")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
}
|
||||
|
||||
// Tags
|
||||
if !link.sortedTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(link.sortedTags.prefix(3), id: \.id) { tag in
|
||||
Text(tag.name)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.2))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
HStack {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "hand.tap")
|
||||
Text("\(link.clickCount)")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(link.linkTypeEnum.displayName)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(linkTypeColor(link.linkTypeEnum))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
linkViewModel.openLink(link)
|
||||
}) {
|
||||
Image(systemName: "arrow.up.right.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
.font(.title2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func linkTypeColor(_ linkType: LinkType) -> Color {
|
||||
switch linkType {
|
||||
case .link:
|
||||
return .blue
|
||||
case .custom:
|
||||
return .green
|
||||
case .template:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SearchView()
|
||||
.environmentObject(LinkViewModel())
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import CoreData
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var showingImportPicker = false
|
||||
@State private var showingImportResult = false
|
||||
@State private var importResultMessage = ""
|
||||
@State private var importedCount = 0
|
||||
@State private var isImporting = false
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink(destination: LanguageSettingsView()) {
|
||||
Label("change_language", systemImage: "globe")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingImportPicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Label("import_links", systemImage: "square.and.arrow.down")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
if isImporting {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isImporting)
|
||||
}
|
||||
}
|
||||
.navigationTitle("settings")
|
||||
.fileImporter(
|
||||
isPresented: $showingImportPicker,
|
||||
allowedContentTypes: [UTType.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
handleImportResult(result)
|
||||
}
|
||||
.alert("import_result", isPresented: $showingImportResult) {
|
||||
Button("ok") { }
|
||||
} message: {
|
||||
Text(importResultMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImportResult(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
importLinks(from: url)
|
||||
case .failure(let error):
|
||||
importResultMessage = String(format: NSLocalizedString("import_error", comment: ""), error.localizedDescription)
|
||||
showingImportResult = true
|
||||
}
|
||||
}
|
||||
|
||||
private func importLinks(from url: URL) {
|
||||
isImporting = true
|
||||
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
importResultMessage = NSLocalizedString("file_access_error", comment: "")
|
||||
showingImportResult = true
|
||||
isImporting = false
|
||||
return
|
||||
}
|
||||
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
var importedCount = 0
|
||||
var skippedCount = 0
|
||||
var failedEntries: [String] = []
|
||||
|
||||
// First try to parse the entire JSON as LinkData array
|
||||
do {
|
||||
let linkDataArray = try JSONDecoder().decode([LinkData].self, from: data)
|
||||
|
||||
// If successful, process normally
|
||||
for linkData in linkDataArray {
|
||||
do {
|
||||
// Check if link with same alias already exists
|
||||
let fetchRequest: NSFetchRequest<Link> = Link.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "alias == %@", linkData.alias.lowercased())
|
||||
|
||||
if try context.count(for: fetchRequest) == 0 {
|
||||
// Create new link
|
||||
let link = Link(context: context)
|
||||
link.alias = linkData.alias.lowercased()
|
||||
link.originalURL = linkData.originalUrl
|
||||
link.linkType = linkData.linkType ?? "LINK"
|
||||
link.text = linkData.text
|
||||
link.createdAt = linkData.createdAt ?? Date()
|
||||
link.updatedAt = linkData.updatedAt ?? Date()
|
||||
link.clickCount = 0
|
||||
|
||||
// Handle tags if present
|
||||
if let tagNames = linkData.tags {
|
||||
for tagName in tagNames {
|
||||
let tagFetchRequest: NSFetchRequest<Tag> = Tag.fetchRequest()
|
||||
tagFetchRequest.predicate = NSPredicate(format: "name == %@", tagName)
|
||||
|
||||
var tag: Tag
|
||||
if let existingTag = try context.fetch(tagFetchRequest).first {
|
||||
tag = existingTag
|
||||
} else {
|
||||
tag = Tag(context: context)
|
||||
tag.name = tagName
|
||||
// Generate slug (required non-optional Core Data attribute)
|
||||
tag.slug = tagName
|
||||
.lowercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
.replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression)
|
||||
tag.createdAt = Date()
|
||||
}
|
||||
link.addToTags(tag)
|
||||
}
|
||||
}
|
||||
|
||||
importedCount += 1
|
||||
} else {
|
||||
skippedCount += 1
|
||||
}
|
||||
} catch {
|
||||
// If individual processing fails, add to failed entries
|
||||
failedEntries.append(linkData.alias)
|
||||
print("Failed to process entry '\(linkData.alias)': \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
// If parsing entire array fails, try individual entry parsing
|
||||
print("Failed to parse as LinkData array, trying individual parsing: \(error)")
|
||||
|
||||
guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else {
|
||||
throw NSError(domain: "ImportError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format - not an array"])
|
||||
}
|
||||
|
||||
for (index, jsonEntry) in jsonArray.enumerated() {
|
||||
do {
|
||||
// Try to parse individual entry
|
||||
let entryData = try JSONSerialization.data(withJSONObject: jsonEntry, options: [])
|
||||
let linkData = try JSONDecoder().decode(LinkData.self, from: entryData)
|
||||
|
||||
// Check if link with same alias already exists
|
||||
let fetchRequest: NSFetchRequest<Link> = Link.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "alias == %@", linkData.alias.lowercased())
|
||||
|
||||
if try context.count(for: fetchRequest) == 0 {
|
||||
// Create new link
|
||||
let link = Link(context: context)
|
||||
link.alias = linkData.alias.lowercased()
|
||||
link.originalURL = linkData.originalUrl
|
||||
link.linkType = linkData.linkType ?? "LINK"
|
||||
link.text = linkData.text
|
||||
link.createdAt = linkData.createdAt ?? Date()
|
||||
link.updatedAt = linkData.updatedAt ?? Date()
|
||||
link.clickCount = 0
|
||||
|
||||
// Handle tags if present
|
||||
if let tagNames = linkData.tags {
|
||||
for tagName in tagNames {
|
||||
let tagFetchRequest: NSFetchRequest<Tag> = Tag.fetchRequest()
|
||||
tagFetchRequest.predicate = NSPredicate(format: "name == %@", tagName)
|
||||
|
||||
var tag: Tag
|
||||
if let existingTag = try context.fetch(tagFetchRequest).first {
|
||||
tag = existingTag
|
||||
} else {
|
||||
tag = Tag(context: context)
|
||||
tag.name = tagName
|
||||
// Generate slug (required non-optional Core Data attribute)
|
||||
tag.slug = tagName
|
||||
.lowercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
.replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression)
|
||||
tag.createdAt = Date()
|
||||
}
|
||||
link.addToTags(tag)
|
||||
}
|
||||
}
|
||||
|
||||
importedCount += 1
|
||||
} else {
|
||||
skippedCount += 1
|
||||
}
|
||||
} catch {
|
||||
// If individual entry fails, collect the alias and continue
|
||||
let alias = jsonEntry["alias"] as? String ?? "Entry #\(index + 1)"
|
||||
failedEntries.append(alias)
|
||||
print("Failed to import entry '\(alias)': \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try context.save()
|
||||
|
||||
// Refresh the link view model
|
||||
linkViewModel.fetchLinks()
|
||||
|
||||
self.importedCount = importedCount
|
||||
|
||||
// Build result message
|
||||
var resultParts: [String] = []
|
||||
|
||||
if importedCount > 0 {
|
||||
resultParts.append(String(format: NSLocalizedString("import_success", comment: ""), importedCount))
|
||||
}
|
||||
|
||||
if skippedCount > 0 {
|
||||
resultParts.append(String(format: NSLocalizedString("import_skipped", comment: ""), skippedCount))
|
||||
}
|
||||
|
||||
if !failedEntries.isEmpty {
|
||||
resultParts.append(String(format: NSLocalizedString("import_failed", comment: ""), failedEntries.count))
|
||||
resultParts.append("Failed entries: \(failedEntries.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
importResultMessage = resultParts.joined(separator: "\n")
|
||||
showingImportResult = true
|
||||
|
||||
} catch {
|
||||
importResultMessage = String(format: NSLocalizedString("import_parse_error", comment: ""), error.localizedDescription)
|
||||
showingImportResult = true
|
||||
}
|
||||
|
||||
isImporting = false
|
||||
}
|
||||
}
|
||||
|
||||
struct LanguageSettingsView: View {
|
||||
@AppStorage("selectedLanguage") private var selectedLanguage = "system"
|
||||
@State private var showingRestartAlert = false
|
||||
|
||||
private let languages = [
|
||||
("system", "system_language", "🌐"),
|
||||
("en", "english", "🇺🇸"),
|
||||
("zh-Hans", "chinese_simplified", "🇨🇳")
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(languages, id: \.0) { code, nameKey, flag in
|
||||
Button(action: {
|
||||
let previousLanguage = selectedLanguage
|
||||
selectedLanguage = code
|
||||
setLanguage(code)
|
||||
|
||||
// Show restart alert if language actually changed
|
||||
if previousLanguage != code {
|
||||
showingRestartAlert = true
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text(flag)
|
||||
Text(NSLocalizedString(nameKey, comment: ""))
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
if selectedLanguage == code {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("change_language")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("language_changed", isPresented: $showingRestartAlert) {
|
||||
Button("ok") { }
|
||||
} message: {
|
||||
Text("restart_app_message")
|
||||
}
|
||||
}
|
||||
|
||||
private func setLanguage(_ languageCode: String) {
|
||||
if languageCode == "system" {
|
||||
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
|
||||
} else {
|
||||
UserDefaults.standard.set([languageCode], forKey: "AppleLanguages")
|
||||
}
|
||||
|
||||
// Note: Language change will take effect after app restart
|
||||
// You could show an alert informing the user about this
|
||||
}
|
||||
}
|
||||
|
||||
// Data structure for importing links
|
||||
struct LinkData: Codable {
|
||||
let alias: String
|
||||
let originalUrl: String?
|
||||
let linkType: String?
|
||||
let text: String?
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
let tags: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case alias
|
||||
case originalUrl = "original_url"
|
||||
case linkType = "link_type"
|
||||
case text
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case tags
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
alias = try container.decode(String.self, forKey: .alias)
|
||||
originalUrl = try container.decodeIfPresent(String.self, forKey: .originalUrl)
|
||||
linkType = try container.decodeIfPresent(String.self, forKey: .linkType)
|
||||
text = try container.decodeIfPresent(String.self, forKey: .text)
|
||||
tags = try container.decodeIfPresent([String].self, forKey: .tags)
|
||||
|
||||
// Handle date parsing with multiple formatters
|
||||
if let createdAtString = try container.decodeIfPresent(String.self, forKey: .createdAt) {
|
||||
createdAt = Self.parseDate(from: createdAtString)
|
||||
} else {
|
||||
createdAt = nil
|
||||
}
|
||||
|
||||
if let updatedAtString = try container.decodeIfPresent(String.self, forKey: .updatedAt) {
|
||||
updatedAt = Self.parseDate(from: updatedAtString)
|
||||
} else {
|
||||
updatedAt = nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseDate(from dateString: String) -> Date? {
|
||||
// Try ISO8601 formatters first
|
||||
let iso8601Formatter1 = ISO8601DateFormatter()
|
||||
|
||||
let iso8601Formatter2 = ISO8601DateFormatter()
|
||||
iso8601Formatter2.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
let iso8601Formatters = [iso8601Formatter1, iso8601Formatter2]
|
||||
|
||||
for formatter in iso8601Formatters {
|
||||
if let date = formatter.date(from: dateString) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
// Try custom DateFormatter patterns
|
||||
let dateFormatter1 = DateFormatter()
|
||||
dateFormatter1.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZZZZZ"
|
||||
|
||||
let dateFormatter2 = DateFormatter()
|
||||
dateFormatter2.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
|
||||
|
||||
let dateFormatter3 = DateFormatter()
|
||||
dateFormatter3.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
||||
|
||||
let dateFormatters = [dateFormatter1, dateFormatter2, dateFormatter3]
|
||||
|
||||
for formatter in dateFormatters {
|
||||
if let date = formatter.date(from: dateString) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
.environmentObject(LinkViewModel())
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
import CoreSpotlight
|
||||
|
||||
struct SpotlightDebugView: View {
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@State private var indexedItemsCount: Int = 0
|
||||
@State private var isSpotlightAvailable: Bool = false
|
||||
@State private var showingAlert = false
|
||||
@State private var alertMessage = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 20) {
|
||||
Section {
|
||||
Text("Spotlight Search Status")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Spotlight Available:")
|
||||
Spacer()
|
||||
Text(isSpotlightAvailable ? "✅ Yes" : "❌ No")
|
||||
.foregroundColor(isSpotlightAvailable ? .green : .red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Indexed Items:")
|
||||
Spacer()
|
||||
Text("\(indexedItemsCount)")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Total Links:")
|
||||
Spacer()
|
||||
Text("\(linkViewModel.links.count)")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(spacing: 15) {
|
||||
Button("Refresh Spotlight Index") {
|
||||
refreshSpotlightIndex()
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Button("Check Index Status") {
|
||||
checkIndexStatus()
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Button("Clear All Indexed Items") {
|
||||
clearSpotlightIndex()
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Instructions:")
|
||||
.font(.headline)
|
||||
|
||||
Text("1. Tap 'Refresh Spotlight Index' to index your links")
|
||||
Text("2. Go to iOS home screen")
|
||||
Text("3. Pull down to open Spotlight search")
|
||||
Text("4. Type 'heygo g' to test search")
|
||||
Text("5. Your links should appear in results")
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBlue).opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Spotlight Debug")
|
||||
.onAppear {
|
||||
checkSpotlightAvailability()
|
||||
checkIndexStatus()
|
||||
}
|
||||
.alert("Spotlight Debug", isPresented: $showingAlert) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkSpotlightAvailability() {
|
||||
isSpotlightAvailable = SpotlightSearchManager.shared.isSpotlightAvailable()
|
||||
}
|
||||
|
||||
private func checkIndexStatus() {
|
||||
linkViewModel.checkSpotlightStatus { count in
|
||||
DispatchQueue.main.async {
|
||||
self.indexedItemsCount = count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSpotlightIndex() {
|
||||
linkViewModel.refreshSpotlightIndex()
|
||||
alertMessage = "Spotlight index refresh initiated. Check status in a few seconds."
|
||||
showingAlert = true
|
||||
|
||||
// Check status after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
checkIndexStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearSpotlightIndex() {
|
||||
SpotlightSearchManager.shared.clearAllIndexedLinks()
|
||||
alertMessage = "All indexed items cleared. You can refresh the index to re-add them."
|
||||
showingAlert = true
|
||||
|
||||
// Update count after clearing
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
indexedItemsCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SpotlightDebugView()
|
||||
.environmentObject(LinkViewModel())
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TagDetailView: View {
|
||||
let tag: Tag
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@State private var showingEditTag = false
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var deleteErrorMessage = ""
|
||||
@State private var showingDeleteError = false
|
||||
@State private var showingDropdownMenu = false
|
||||
|
||||
var canDeleteTag: Bool {
|
||||
tag.linkCount == 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
titleAndActionsSection
|
||||
headerSection
|
||||
statisticsSection
|
||||
linksSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
|
||||
// Dropdown menu overlay
|
||||
if showingDropdownMenu {
|
||||
Color.black.opacity(0.001)
|
||||
.onTapGesture {
|
||||
showingDropdownMenu = false
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
dropdownMenu
|
||||
.padding(.top, 100)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingEditTag) {
|
||||
TagEditView(tag: tag)
|
||||
.environment(\.managedObjectContext, viewContext)
|
||||
}
|
||||
.alert("delete_tag", isPresented: $showingDeleteAlert) {
|
||||
Button("cancel", role: .cancel) { }
|
||||
Button("delete", role: .destructive) {
|
||||
deleteTag()
|
||||
}
|
||||
} message: {
|
||||
Text("delete_tag_confirmation")
|
||||
}
|
||||
.alert("error", isPresented: $showingDeleteError) {
|
||||
Button("ok") { }
|
||||
} message: {
|
||||
Text(deleteErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private var titleAndActionsSection: some View {
|
||||
HStack {
|
||||
Button(action: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Text("Link Details")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("tag_details")
|
||||
.font(.system(size: 16))
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showingDropdownMenu.toggle()
|
||||
}) {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var dropdownMenu: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button(action: {
|
||||
showingDropdownMenu = false
|
||||
showingEditTag = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "pencil")
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 20)
|
||||
Text("edit_tag")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(action: {
|
||||
showingDropdownMenu = false
|
||||
if canDeleteTag {
|
||||
showingDeleteAlert = true
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(canDeleteTag ? .red : .gray)
|
||||
Text("delete_tag")
|
||||
.foregroundColor(canDeleteTag ? .red : .gray)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.disabled(!canDeleteTag)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(10)
|
||||
.shadow(color: Color.black.opacity(0.1), radius: 10, x: 0, y: 5)
|
||||
.frame(width: 160)
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(tag.name)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("tag")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.orange)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
if let description = tag.tagDescription, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private var statisticsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("statistics")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
SimpleStatCard(
|
||||
title: "associated_links",
|
||||
value: "\(tag.linkCount)",
|
||||
icon: "link"
|
||||
)
|
||||
|
||||
SimpleStatCard(
|
||||
title: "created",
|
||||
value: tag.createdAt.formatted(.dateTime.year().month().day()),
|
||||
icon: "calendar"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var linksSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("links")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(tag.sortedLinks.count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if tag.sortedLinks.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "link.badge.plus")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.gray)
|
||||
|
||||
Text("no_links_with_tag")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
} else {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(tag.sortedLinks, id: \.id) { link in
|
||||
NavigationLink(destination: LinkDetailView(link: link)) {
|
||||
TagLinkRowView(link: link)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteTag() {
|
||||
guard canDeleteTag else {
|
||||
deleteErrorMessage = NSLocalizedString("cannot_delete_tag_with_links", comment: "Cannot delete tag with associated links")
|
||||
showingDeleteError = true
|
||||
return
|
||||
}
|
||||
|
||||
viewContext.delete(tag)
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
linkViewModel.fetchLinks() // Refresh the links list
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} catch {
|
||||
deleteErrorMessage = error.localizedDescription
|
||||
showingDeleteError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper view for displaying links in the tag detail
|
||||
struct TagLinkRowView: View {
|
||||
let link: Link
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
// Link type indicator
|
||||
Image(systemName: linkTypeIcon)
|
||||
.foregroundColor(linkTypeColor)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(link.alias)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if let description = link.linkDescription, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let url = link.originalURL {
|
||||
Text(url)
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(link.clickCount)")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text("clicks")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Tags (if any)
|
||||
if !link.sortedTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(link.sortedTags, id: \.id) { tag in
|
||||
Text(tag.name)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.2))
|
||||
.foregroundColor(.blue)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private var linkTypeIcon: String {
|
||||
switch link.linkTypeEnum {
|
||||
case .link:
|
||||
return "link"
|
||||
case .custom:
|
||||
return "doc.text"
|
||||
case .template:
|
||||
return "doc.badge.plus"
|
||||
}
|
||||
}
|
||||
|
||||
private var linkTypeColor: Color {
|
||||
switch link.linkTypeEnum {
|
||||
case .link:
|
||||
return .blue
|
||||
case .custom:
|
||||
return .green
|
||||
case .template:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse SimpleStatCard component for consistent styling
|
||||
struct SimpleStatCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.blue)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(value)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let context = PersistenceController.preview.container.viewContext
|
||||
let tag = Tag(context: context)
|
||||
tag.name = "Sample Tag"
|
||||
tag.tagDescription = "This is a sample tag description for testing the tag detail view."
|
||||
tag.createdAt = Date()
|
||||
|
||||
return TagDetailView(tag: tag)
|
||||
.environment(\.managedObjectContext, context)
|
||||
.environmentObject(LinkViewModel())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TagEditView: View {
|
||||
let tag: Tag
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@EnvironmentObject var linkViewModel: LinkViewModel
|
||||
|
||||
@State private var tagDescription: String = ""
|
||||
@State private var saveErrorMessage: String = ""
|
||||
@State private var showingSaveError: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("tag_name")
|
||||
.font(.headline)
|
||||
|
||||
Text(tag.name)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
} header: {
|
||||
Text("tag_information")
|
||||
} footer: {
|
||||
Text("tag_name_cannot_be_changed")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("description")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $tagDescription)
|
||||
.frame(minHeight: 100)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
} header: {
|
||||
Text("tag_description")
|
||||
} footer: {
|
||||
Text("add_description_to_help_organize_links")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("associated_links")
|
||||
.font(.headline)
|
||||
Text("\(tag.linkCount) links")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
Text("created")
|
||||
.font(.headline)
|
||||
Text(tag.createdAt, format: .dateTime.year().month().day())
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("statistics")
|
||||
}
|
||||
}
|
||||
.navigationTitle("edit_tag")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("cancel") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("save") {
|
||||
saveTag()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
tagDescription = tag.tagDescription ?? ""
|
||||
}
|
||||
.alert("error", isPresented: $showingSaveError) {
|
||||
Button("ok") { }
|
||||
} message: {
|
||||
Text(saveErrorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveTag() {
|
||||
// Update the tag description
|
||||
tag.tagDescription = tagDescription.isEmpty ? nil : tagDescription
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
linkViewModel.fetchLinks() // Refresh if needed
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} catch {
|
||||
saveErrorMessage = error.localizedDescription
|
||||
showingSaveError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let context = PersistenceController.preview.container.viewContext
|
||||
let tag = Tag(context: context)
|
||||
tag.name = "Sample Tag"
|
||||
tag.tagDescription = "This is a sample tag description."
|
||||
tag.createdAt = Date()
|
||||
|
||||
return TagEditView(tag: tag)
|
||||
.environment(\.managedObjectContext, context)
|
||||
.environmentObject(LinkViewModel())
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
# iOS Spotlight Search Integration - Implementation Summary
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. `/Utils/SpotlightSearchManager.swift`
|
||||
- **Purpose**: Core Spotlight search integration manager
|
||||
- **Key Features**:
|
||||
- Indexes links in iOS Spotlight search
|
||||
- Handles search result selection
|
||||
- Manages link opening and click tracking
|
||||
- Rate limiting and error handling
|
||||
- Debug utilities
|
||||
|
||||
### 2. `/Views/SpotlightDebugView.swift`
|
||||
- **Purpose**: Debug interface for testing Spotlight integration
|
||||
- **Key Features**:
|
||||
- Shows Spotlight availability status
|
||||
- Displays indexed items count
|
||||
- Manual index refresh controls
|
||||
- Testing instructions
|
||||
|
||||
### 3. `/mobile/SPOTLIGHT_SEARCH_README.md`
|
||||
- **Purpose**: User documentation for Spotlight search feature
|
||||
- **Contents**: Usage instructions, examples, troubleshooting
|
||||
|
||||
### 4. `/mobile/TESTING_GUIDE.md`
|
||||
- **Purpose**: Developer testing guide
|
||||
- **Contents**: Step-by-step testing procedures, verification steps
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. `/ViewModels/LinkViewModel.swift`
|
||||
**Changes Added**:
|
||||
- Import `CoreSpotlight`
|
||||
- `initializeSpotlightSearch()` - Initialize indexing on app start
|
||||
- `refreshSpotlightIndex()` - Manual index refresh
|
||||
- `checkSpotlightStatus()` - Debug status checking
|
||||
- Auto-indexing in `createLink()`, `updateLink()`, `deleteLink()`
|
||||
|
||||
### 2. `/HeygoApp.swift`
|
||||
**Changes Added**:
|
||||
- Import `CoreSpotlight`
|
||||
- `@StateObject` for shared `LinkViewModel`
|
||||
- `.onContinueUserActivity()` handler for Spotlight search results
|
||||
- `handleSpotlightSearch()` method
|
||||
|
||||
### 3. `/ContentView.swift`
|
||||
**Changes Added**:
|
||||
- Use `@EnvironmentObject` instead of `@StateObject` for `LinkViewModel`
|
||||
- Notification handlers for custom/template link content
|
||||
- Debug tab (conditional for DEBUG builds)
|
||||
- `handleShowLinkContent()` and `handleShowTemplateParameterInput()` methods
|
||||
|
||||
## Key Functionality Implemented
|
||||
|
||||
### Automatic Indexing
|
||||
- **When**: Links created, updated, or deleted
|
||||
- **What**: Searchable items with rich metadata
|
||||
- **Where**: iOS Spotlight search index
|
||||
|
||||
### Search Features
|
||||
- **Prefix Matching**: "heygo g" finds "google", "github", etc.
|
||||
- **Tag Search**: Find links by associated tags
|
||||
- **Rich Results**: Shows link type icons and descriptions
|
||||
- **Ranking**: Frequently clicked links rank higher
|
||||
|
||||
### Link Opening
|
||||
- **Regular Links**: Open directly in Safari browser
|
||||
- **Custom Links**: Open app to show content (placeholder)
|
||||
- **Template Links**: Open app for parameter input (placeholder)
|
||||
|
||||
### Click Tracking
|
||||
- **Automatic**: Every Spotlight-opened link records a click
|
||||
- **Integrated**: Uses existing `recordClick()` method
|
||||
- **Analytics**: Click data appears in app analytics
|
||||
|
||||
### Error Handling
|
||||
- **Rate Limiting**: Prevents excessive re-indexing
|
||||
- **Availability Check**: Verifies Spotlight support
|
||||
- **Graceful Failures**: Continues working if indexing fails
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Data Flow
|
||||
1. **Link CRUD** → Auto-trigger indexing
|
||||
2. **iOS Spotlight** → User searches "heygo term"
|
||||
3. **User Selection** → App handles via `onContinueUserActivity`
|
||||
4. **Link Opening** → Safari or app, plus click tracking
|
||||
|
||||
### Integration Points
|
||||
- **Core Data**: Links automatically indexed on changes
|
||||
- **Core Spotlight**: iOS system search integration
|
||||
- **UIKit**: URL opening in external browser
|
||||
- **SwiftUI**: Notification-based custom content handling
|
||||
|
||||
### Performance Considerations
|
||||
- **Background Indexing**: Heavy operations on background queue
|
||||
- **Rate Limiting**: 5-minute minimum between full re-indexes
|
||||
- **Incremental Updates**: Individual links indexed immediately
|
||||
- **Memory Efficient**: Streams search results, doesn't load all at once
|
||||
|
||||
## User Experience
|
||||
|
||||
### Discovery
|
||||
- Users type "heygo" in iOS search to see all links
|
||||
- Prefix searching: "heygo g" shows links starting with 'g'
|
||||
- Tag-based discovery: Search by tag names
|
||||
|
||||
### Quick Access
|
||||
- One-tap access to frequently used links
|
||||
- No need to open the app for regular link access
|
||||
- Integrates with iOS muscle memory (pull-down search)
|
||||
|
||||
### Analytics Integration
|
||||
- All Spotlight-accessed links count toward usage analytics
|
||||
- Maintains existing click tracking and statistics
|
||||
- Helps identify most valuable links
|
||||
|
||||
## Future Enhancement Opportunities
|
||||
|
||||
### 1. Custom Link Content Display
|
||||
- Implement rich content viewer for custom links
|
||||
- Markdown rendering in modal or dedicated view
|
||||
- Potential deep linking to specific content sections
|
||||
|
||||
### 2. Template Parameter Input
|
||||
- Create parameter input interface for template links
|
||||
- Pre-fill with common values or history
|
||||
- Quick actions for frequently used parameters
|
||||
|
||||
### 3. Advanced Search Features
|
||||
- Date-based filtering in search results
|
||||
- Search within link content for custom links
|
||||
- Saved searches or search shortcuts
|
||||
|
||||
### 4. Enhanced Metadata
|
||||
- Website favicon extraction for link icons
|
||||
- Preview generation for link content
|
||||
- Rich snippets with link statistics
|
||||
|
||||
This implementation provides a solid foundation for iOS Spotlight search integration while maintaining the existing app functionality and user experience.
|
||||
@@ -0,0 +1,199 @@
|
||||
# Heygo iOS App (黑狗)
|
||||
|
||||
A comprehensive iOS link management application that replicates the functionality of the Django-based link management system. Built with SwiftUI, Core Data, and modern iOS development practices.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Link Management
|
||||
- ✅ Create, edit, and delete links with unique aliases
|
||||
- ✅ Support for regular links, custom markdown content, and template URLs
|
||||
- ✅ URL template processing with parameter placeholders (`{param,default=value}`)
|
||||
- ✅ Real-time alias validation and duplicate prevention
|
||||
- ✅ Click tracking and analytics
|
||||
|
||||
### Advanced Features
|
||||
- ✅ Tag-based organization with auto-completion
|
||||
- ✅ Powerful search with fuzzy matching
|
||||
- ✅ Comprehensive analytics with charts
|
||||
- ✅ Markdown rendering for custom content
|
||||
- ✅ Multi-language support (English and Chinese)
|
||||
- ✅ Dark mode support
|
||||
- ✅ Accessibility features
|
||||
|
||||
### Analytics & Insights
|
||||
- ✅ Click tracking with timestamps
|
||||
- ✅ Visual charts showing usage trends
|
||||
- ✅ Top performing links analysis
|
||||
- ✅ Link type distribution
|
||||
- ✅ Time-based filtering (1 week to all time)
|
||||
- ✅ Export capabilities
|
||||
|
||||
### User Experience
|
||||
- ✅ Clean, modern SwiftUI interface
|
||||
- ✅ Intuitive navigation with tab-based structure
|
||||
- ✅ Pull-to-refresh functionality
|
||||
- ✅ Swipe actions for quick operations
|
||||
- ✅ Real-time search and filtering
|
||||
- ✅ Template parameter input dialogs
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Pattern
|
||||
- **MVVM** (Model-View-ViewModel) with Combine framework
|
||||
- **Core Data** for persistent storage
|
||||
- **SwiftUI** for modern, declarative UI
|
||||
|
||||
### Key Components
|
||||
|
||||
#### Models
|
||||
- `Link` - Core link entity with alias, URL, type, and metadata
|
||||
- `Tag` - Categorization system with many-to-many relationship
|
||||
- `ClickLog` - Track individual clicks with timestamps
|
||||
- `ChangeLog` - Audit trail for link modifications
|
||||
|
||||
#### ViewModels
|
||||
- `LinkViewModel` - Manages link CRUD operations and business logic
|
||||
- `TagManager` - Handles tag creation, search, and suggestions
|
||||
|
||||
#### Views
|
||||
- `LinkListView` - Main interface showing all links with search/filter
|
||||
- `LinkFormView` - Create/edit links with validation
|
||||
- `LinkDetailView` - Comprehensive link information and analytics
|
||||
- `SearchView` - Advanced search with filters and suggestions
|
||||
- `AnalyticsView` - Charts and insights dashboard
|
||||
|
||||
#### Utilities
|
||||
- `URLTemplateProcessor` - Parse and process template URLs
|
||||
- `MarkdownRenderer` - Render markdown content to AttributedString
|
||||
- `AnalyticsCalculator` - Generate chart data and statistics
|
||||
- `PersistenceController` - Core Data stack management
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Frameworks & Libraries
|
||||
- **SwiftUI** - Modern declarative UI framework
|
||||
- **Core Data** - Local data persistence
|
||||
- **Charts** - Native charting framework (iOS 16+)
|
||||
- **Combine** - Reactive programming framework
|
||||
- **Foundation** - Core system services
|
||||
|
||||
### iOS Features Used
|
||||
- **Universal App** - Supports iPhone and iPad
|
||||
- **Internationalization** - English and Chinese localization
|
||||
- **Accessibility** - VoiceOver and accessibility features
|
||||
- **URL Schemes** - Handle external link opening
|
||||
- **Share Extensions** - (Future enhancement)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Heygo/
|
||||
├── HeygoApp.swift # App entry point
|
||||
├── ContentView.swift # Main tab navigation
|
||||
├── Views/ # SwiftUI views
|
||||
│ ├── LinkListView.swift # Main link list interface
|
||||
│ ├── LinkFormView.swift # Link creation/editing
|
||||
│ ├── LinkDetailView.swift # Detailed link information
|
||||
│ ├── SearchView.swift # Advanced search interface
|
||||
│ └── AnalyticsView.swift # Analytics dashboard
|
||||
├── ViewModels/ # Business logic
|
||||
│ └── LinkViewModel.swift # Main view model
|
||||
├── Models/ # Core Data entities
|
||||
│ ├── CoreDataModel.xcdatamodeld
|
||||
│ ├── PersistenceController.swift
|
||||
│ └── [Entity]+CoreDataClass.swift
|
||||
├── Utils/ # Utility classes
|
||||
│ ├── URLTemplateProcessor.swift
|
||||
│ ├── TagManager.swift
|
||||
│ ├── MarkdownRenderer.swift
|
||||
│ └── AnalyticsCalculator.swift
|
||||
└── Resources/ # Assets and localization
|
||||
├── Assets.xcassets
|
||||
├── Info.plist
|
||||
├── en.lproj/
|
||||
└── zh-Hans.lproj/
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- **iOS 17.0+** (for Charts framework and latest SwiftUI features)
|
||||
- **Xcode 15.0+**
|
||||
- **Swift 5.9+**
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
1. Open `Heygo.xcodeproj` in Xcode
|
||||
2. Select your development team in project settings
|
||||
3. Choose your target device or simulator
|
||||
4. Build and run (⌘+R)
|
||||
|
||||
## Key Features Implementation
|
||||
|
||||
### URL Template System
|
||||
The app supports dynamic URL templates with parameters:
|
||||
```
|
||||
https://google.com/search?q={query}
|
||||
https://github.com/{user}/{repo}
|
||||
https://maps.apple.com/?q={location,default=New York}
|
||||
```
|
||||
|
||||
### Analytics Engine
|
||||
Comprehensive analytics with multiple time ranges and metrics:
|
||||
- Click trends over time
|
||||
- Top performing links
|
||||
- Link type distribution
|
||||
- Activity timeline
|
||||
|
||||
### Tag Management
|
||||
Intelligent tag system with:
|
||||
- Auto-completion based on existing tags
|
||||
- Tag suggestions based on URL patterns
|
||||
- Bulk tag operations
|
||||
- Usage statistics
|
||||
|
||||
### Search & Filter
|
||||
Advanced search capabilities:
|
||||
- Fuzzy text matching
|
||||
- Filter by link type and tags
|
||||
- Search history
|
||||
- Quick URL opening
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Lazy Loading** - Large link collections loaded incrementally
|
||||
- **Core Data Optimization** - Proper indexing and fetch request optimization
|
||||
- **Memory Management** - Efficient image and data caching
|
||||
- **Background Processing** - Analytics calculations performed off main thread
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **VoiceOver Support** - All UI elements properly labeled
|
||||
- **Dynamic Type** - Respects user's text size preferences
|
||||
- **High Contrast** - Supports accessibility color schemes
|
||||
- **Keyboard Navigation** - Full keyboard accessibility
|
||||
|
||||
## Localization
|
||||
|
||||
Currently supports:
|
||||
- **English (en)**
|
||||
- **Simplified Chinese (zh-Hans)**
|
||||
|
||||
Easy to extend for additional languages by adding new `.lproj` directories.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] iCloud sync capabilities
|
||||
- [ ] Share extension for adding links from other apps
|
||||
- [ ] Widgets for quick access to popular links
|
||||
- [ ] Shortcuts app integration
|
||||
- [ ] Export/import functionality
|
||||
- [ ] QR code generation for links
|
||||
- [ ] Backup and restore features
|
||||
|
||||
## Contributing
|
||||
|
||||
This iOS app is designed to be a faithful replica of the Django web application. When adding new features, ensure they maintain parity with the web version while taking advantage of iOS-specific capabilities.
|
||||
|
||||
## License
|
||||
|
||||
[Add your license information here]
|
||||
@@ -0,0 +1,76 @@
|
||||
# iOS Spotlight Search Integration for Heygo
|
||||
|
||||
## Overview
|
||||
|
||||
The Heygo iOS app now supports system-wide Spotlight search integration. Users can search for their links directly from the iOS search screen without opening the app.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Searching for Links
|
||||
1. Pull down from the home screen or swipe right to access iOS Spotlight search
|
||||
2. Type "heygo" followed by a space and then the beginning of your link alias
|
||||
- Example: "heygo g" will show links that start with 'g' or contain 'g'
|
||||
- Example: "heygo github" will show links with "github" in the alias, description, or tags
|
||||
|
||||
### Opening Links
|
||||
When you tap on a search result:
|
||||
- **Regular Links**: Opens the URL directly in your default browser
|
||||
- **Template Links**:
|
||||
- If no parameters needed: Opens directly in browser
|
||||
- If parameters needed: Opens the app to input parameters
|
||||
- **Custom Links**: Opens the app to display the custom content
|
||||
|
||||
### Click Tracking
|
||||
Every time you open a link through Spotlight search, the app automatically:
|
||||
- Increments the click counter for that link
|
||||
- Records a click log entry with timestamp
|
||||
- Updates the link's ranking for future searches
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Files Modified/Created
|
||||
1. **`Utils/SpotlightSearchManager.swift`** - Core Spotlight integration logic
|
||||
2. **`ViewModels/LinkViewModel.swift`** - Added automatic indexing when links are created/updated/deleted
|
||||
3. **`HeygoApp.swift`** - Added user activity handling for Spotlight search results
|
||||
4. **`ContentView.swift`** - Added notification handling for custom/template links
|
||||
|
||||
### Key Features
|
||||
- **Automatic Indexing**: Links are automatically indexed when created or updated
|
||||
- **Prefix Matching**: Search supports partial matches (typing "g" finds "github", "google", etc.)
|
||||
- **Ranking**: More frequently clicked links rank higher in search results
|
||||
- **Rich Metadata**: Search results show link type icons and descriptions
|
||||
- **Clean Removal**: Deleted links are automatically removed from search index
|
||||
|
||||
### Search Optimization
|
||||
The search implementation includes:
|
||||
- App identifier keywords ("heygo", "link") for filtering
|
||||
- Alias prefix matching for quick access
|
||||
- Tag-based searching
|
||||
- Link type classification
|
||||
- Click-based ranking
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Quick Link Access
|
||||
- Search: "heygo g" → Shows all links starting with 'g'
|
||||
- Search: "heygo gmail" → Shows gmail-related links
|
||||
- Search: "heygo" → Shows all your links
|
||||
|
||||
### Tag-Based Search
|
||||
- Search: "heygo work" → Shows links tagged with "work"
|
||||
- Search: "heygo social" → Shows links tagged with "social"
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Links Not Appearing in Search
|
||||
1. Make sure the app has been opened recently (triggers indexing)
|
||||
2. Try searching with "heygo" prefix
|
||||
3. Check if Spotlight indexing is enabled in iOS Settings → Siri & Search → Heygo
|
||||
|
||||
### Search Results Not Opening Correctly
|
||||
1. Ensure the app is installed and not deleted
|
||||
2. Check if the link still exists in your collection
|
||||
3. For template links, make sure parameters are properly configured
|
||||
|
||||
## Privacy Note
|
||||
Link indexing happens locally on your device. Your links are not sent to Apple's servers beyond what's necessary for local Spotlight functionality.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Template Link Enhancement Implementation
|
||||
|
||||
This document summarizes the enhancements made to the iOS app's template link functionality.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Enhanced Template Help Text
|
||||
- **Location**: LinkFormView.swift (when template type is selected)
|
||||
- **Content**: Added detailed explanation with example
|
||||
```
|
||||
Template type allows you to create dynamic URLs with parameters. Use {query, default=value} syntax in the URL.
|
||||
|
||||
Example: https://google.com/search?q={query, default=hello}
|
||||
```
|
||||
|
||||
### 2. Real-time Template Validation
|
||||
- **Visual feedback with iOS blue highlighting** when template is valid
|
||||
- **Warning messages** when template has no parameters
|
||||
- **Error messages** when template syntax is invalid
|
||||
|
||||
### 3. Border Color Indicators
|
||||
- **Blue border**: Valid template with parameters
|
||||
- **Orange border**: Invalid URL or template syntax error
|
||||
- **Clear border**: Normal state
|
||||
|
||||
### 4. Template Validation States
|
||||
- ✅ **Valid with parameters**: Shows blue checkmark with "Valid template with parameters"
|
||||
- ⚠️ **No parameters**: Shows orange warning "Template should contain parameters like {query} or {query,default=value}"
|
||||
- ❌ **Invalid syntax**: Shows red error "Invalid template syntax. Use {parameter} or {parameter,default=value}"
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### URLTemplateProcessor Enhancements
|
||||
- **Updated regex pattern** to handle flexible spacing: `{query, default=value}` vs `{query,default=value}`
|
||||
- **Improved parameter extraction** with whitespace trimming
|
||||
- **Enhanced validation** using existing processor methods
|
||||
|
||||
### LinkFormView Changes
|
||||
- **New computed property**: `templateValidationView` for real-time feedback
|
||||
- **Enhanced border styling**: `getBorderColor()` method for dynamic colors
|
||||
- **Improved URL validation**: Better template-specific validation logic
|
||||
|
||||
### Localization Updates
|
||||
- **English**: Added `invalid_template_format` and `invalid_base_url_format`
|
||||
- **Chinese**: Added corresponding translations
|
||||
|
||||
## Supported Template Formats
|
||||
|
||||
The implementation supports both syntax formats:
|
||||
- `{parameter}` - Required parameter
|
||||
- `{parameter, default=value}` - Parameter with default value (flexible spacing)
|
||||
- `{parameter,default=value}` - Parameter with default value (no spacing)
|
||||
|
||||
## Example Templates
|
||||
|
||||
✅ **Valid Templates:**
|
||||
- `https://google.com/search?q={query}`
|
||||
- `https://google.com/search?q={query, default=hello}`
|
||||
- `https://github.com/{user}/{repo}`
|
||||
- `https://maps.apple.com/?q={location,default=New York}`
|
||||
|
||||
⚠️ **Templates needing parameters:**
|
||||
- `https://example.com/no-parameters`
|
||||
|
||||
❌ **Invalid syntax examples:**
|
||||
- URLs with malformed parameter syntax
|
||||
- Non-URL strings when template type is selected
|
||||
|
||||
## User Experience
|
||||
|
||||
1. **Select Template type** in link form
|
||||
2. **See helpful tips** with syntax explanation and example
|
||||
3. **Enter URL with parameters** - real-time validation appears
|
||||
4. **Visual feedback**:
|
||||
- Blue border and checkmark for valid templates
|
||||
- Orange warning for templates without parameters
|
||||
- Red error for invalid syntax
|
||||
5. **Clear error messages** to guide correction
|
||||
|
||||
The implementation provides a smooth, intuitive experience for creating dynamic URL templates with immediate feedback.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Testing iOS Spotlight Search Integration
|
||||
|
||||
## Quick Test Guide
|
||||
|
||||
### 1. Build and Run the App
|
||||
1. Build the Heygo iOS app in Xcode
|
||||
2. Install it on a physical device or simulator (iOS 9.0+)
|
||||
3. Open the app and create a few test links if you don't have any
|
||||
|
||||
### 2. Test Basic Functionality
|
||||
1. Create a test link with alias "google" pointing to "https://google.com"
|
||||
2. Go to the debug tab (gear icon - only visible in debug builds)
|
||||
3. Tap "Refresh Spotlight Index"
|
||||
4. Wait for the success message
|
||||
|
||||
### 3. Test Spotlight Search
|
||||
1. Go to the iOS home screen
|
||||
2. Pull down from the top to open Spotlight search
|
||||
3. Type "heygo g"
|
||||
4. You should see your "google" link appear in the search results
|
||||
5. Tap on the result - it should open Google in Safari and increment the click counter
|
||||
|
||||
### 4. Verify Click Tracking
|
||||
1. After opening a link from Spotlight, return to the Heygo app
|
||||
2. Go to the Analytics tab
|
||||
3. Verify that the click count for the link has increased
|
||||
4. Check that a new click log entry was created
|
||||
|
||||
### 5. Test Different Link Types
|
||||
|
||||
#### Regular Links
|
||||
- Create a link with alias "github" → "https://github.com"
|
||||
- Search "heygo github" in Spotlight
|
||||
- Should open GitHub in Safari
|
||||
|
||||
#### Custom Links
|
||||
- Create a custom link with some markdown content
|
||||
- Search for it in Spotlight
|
||||
- Should open the app and show the content (placeholder for now)
|
||||
|
||||
#### Template Links
|
||||
- Create a template link with URL containing parameters like "https://google.com/search?q={query}"
|
||||
- Search for it in Spotlight
|
||||
- Should open the app for parameter input (placeholder for now)
|
||||
|
||||
### 6. Advanced Testing
|
||||
|
||||
#### Prefix Search
|
||||
- Create links: "gmail", "github", "google"
|
||||
- Search "heygo g" - should show all three
|
||||
- Search "heygo git" - should show only github
|
||||
|
||||
#### Tag-Based Search
|
||||
- Create a link with tags "work", "social"
|
||||
- Search "heygo work" - should find the tagged link
|
||||
|
||||
#### Ranking Test
|
||||
- Create two similar links
|
||||
- Click one multiple times through the app
|
||||
- Search for both - the frequently clicked one should appear higher
|
||||
|
||||
## Debugging Issues
|
||||
|
||||
### Links Not Appearing in Search
|
||||
1. Check the debug tab - verify "Spotlight Available" shows ✅
|
||||
2. Verify "Indexed Items" count matches your link count
|
||||
3. Try refreshing the index
|
||||
4. Make sure you're using the "heygo" prefix in your search
|
||||
|
||||
### Links Not Opening Correctly
|
||||
1. Check that the link exists in the app
|
||||
2. Verify the URL is properly formatted for regular links
|
||||
3. Check the debug console for error messages
|
||||
|
||||
### Performance Issues
|
||||
1. The system rate-limits full re-indexing to every 5 minutes
|
||||
2. Individual link updates are indexed immediately
|
||||
3. Large link collections (100+) may take a few seconds to index
|
||||
|
||||
## Technical Verification
|
||||
|
||||
### Check Spotlight Index Status
|
||||
1. Use the debug tab to see indexed item count
|
||||
2. Compare with total link count in the app
|
||||
3. Use "Clear All Indexed Items" to reset if needed
|
||||
|
||||
### Verify Core Data Integration
|
||||
1. Create a new link → should auto-index
|
||||
2. Edit an existing link → should re-index
|
||||
3. Delete a link → should remove from index
|
||||
|
||||
### Test Error Handling
|
||||
1. Try searching when network is off (should still work - local index)
|
||||
2. Force-quit the app during indexing (should recover on next launch)
|
||||
3. Delete the app and reinstall (should rebuild index)
|
||||
|
||||
## Expected Behavior Summary
|
||||
|
||||
| Action | Expected Result |
|
||||
|--------|----------------|
|
||||
| Create link | Automatically indexed in Spotlight |
|
||||
| Edit link | Re-indexed with updated information |
|
||||
| Delete link | Removed from Spotlight index |
|
||||
| Search "heygo [term]" | Shows matching links |
|
||||
| Tap search result | Opens link and records click |
|
||||
| Regular link | Opens in Safari |
|
||||
| Custom link | Opens app (placeholder) |
|
||||
| Template link | Opens app for parameters (placeholder) |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. Custom and template link handling shows placeholders - UI implementation needed
|
||||
2. Template parameter input is not yet implemented - shows placeholder
|
||||
3. Search results limited by iOS Spotlight constraints
|
||||
4. Indexing happens on background thread - may have slight delay
|
||||
5. Rate limiting prevents excessive re-indexing
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import Foundation
|
||||
|
||||
// Copy the URLTemplateProcessor code for testing
|
||||
struct URLTemplateProcessor {
|
||||
static func processTemplate(_ template: String, with parameters: [String: String]) -> String {
|
||||
var processedURL = template
|
||||
let extractedParams = extractParameters(from: template)
|
||||
|
||||
for param in extractedParams {
|
||||
let placeholder = "{\(param.name)" + (param.defaultValue != nil ? ",default=\(param.defaultValue!)" : "") + "}"
|
||||
let value = parameters[param.name] ?? param.defaultValue ?? ""
|
||||
processedURL = processedURL.replacingOccurrences(of: placeholder, with: value)
|
||||
}
|
||||
|
||||
return processedURL
|
||||
}
|
||||
|
||||
static func extractParameters(from template: String) -> [TemplateParameter] {
|
||||
var parameters: [TemplateParameter] = []
|
||||
// Updated pattern to handle optional spaces around comma and default=
|
||||
let pattern = "\\{([^,}]+)(?:\\s*,\\s*default\\s*=\\s*([^}]+))?\\}"
|
||||
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
return parameters
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.utf16.count))
|
||||
|
||||
for match in matches {
|
||||
let nameRange = match.range(at: 1)
|
||||
let defaultRange = match.range(at: 2)
|
||||
|
||||
if let nameSwiftRange = Range(nameRange, in: template) {
|
||||
let name = String(template[nameSwiftRange]).trimmingCharacters(in: .whitespaces)
|
||||
var defaultValue: String? = nil
|
||||
|
||||
if defaultRange.location != NSNotFound,
|
||||
let defaultSwiftRange = Range(defaultRange, in: template) {
|
||||
defaultValue = String(template[defaultSwiftRange]).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
parameters.append(TemplateParameter(name: name, defaultValue: defaultValue))
|
||||
}
|
||||
}
|
||||
|
||||
return parameters
|
||||
}
|
||||
|
||||
static func validateTemplate(_ template: String) -> Bool {
|
||||
let extractedParams = extractParameters(from: template)
|
||||
|
||||
// Check if all placeholders are valid
|
||||
let pattern = "\\{[^{}]*\\}"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.utf16.count))
|
||||
|
||||
// Each match should correspond to a valid parameter
|
||||
for match in matches {
|
||||
if let matchRange = Range(match.range, in: template) {
|
||||
let matchString = String(template[matchRange])
|
||||
let isValid = extractedParams.contains { param in
|
||||
matchString.contains(param.name)
|
||||
}
|
||||
if !isValid {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
static func hasParameters(_ template: String) -> Bool {
|
||||
return template.contains("{") && template.contains("}")
|
||||
}
|
||||
}
|
||||
|
||||
struct TemplateParameter {
|
||||
let name: String
|
||||
let defaultValue: String?
|
||||
|
||||
var isRequired: Bool {
|
||||
return defaultValue == nil
|
||||
}
|
||||
}
|
||||
|
||||
// Test cases
|
||||
print("Testing URLTemplateProcessor...")
|
||||
|
||||
let testCases = [
|
||||
"https://google.com/search?q={query, default=hello}",
|
||||
"https://google.com/search?q={query}",
|
||||
"https://github.com/{user}/{repo}",
|
||||
"https://youtube.com/watch?v={videoId}",
|
||||
"https://maps.apple.com/?q={location,default=New York}",
|
||||
"https://example.com/{invalid syntax}",
|
||||
"https://example.com/no-parameters",
|
||||
"https://example.com/{query,default=test}/{page,default=1}"
|
||||
]
|
||||
|
||||
for testCase in testCases {
|
||||
let isValid = URLTemplateProcessor.validateTemplate(testCase)
|
||||
let hasParams = URLTemplateProcessor.hasParameters(testCase)
|
||||
let parameters = URLTemplateProcessor.extractParameters(from: testCase)
|
||||
|
||||
print("\nTest: \(testCase)")
|
||||
print(" Valid: \(isValid)")
|
||||
print(" Has Parameters: \(hasParams)")
|
||||
print(" Parameters: \(parameters.map { "\($0.name)" + (($0.defaultValue != nil) ? ",default=\($0.defaultValue!)" : "") })")
|
||||
|
||||
if isValid && hasParams {
|
||||
print(" ✅ Would show blue border")
|
||||
} else if !hasParams {
|
||||
print(" ⚠️ Would show warning (no parameters)")
|
||||
} else {
|
||||
print(" ❌ Would show error (invalid)")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
set -eu
|
||||
source .venv/bin/activate
|
||||
echo "Starting Django development server..."
|
||||
python manage.py runserver
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
|
||||
Reference in New Issue
Block a user