Merge pull request #32 from wahyd4/ios

ios App
This commit is contained in:
2025-08-16 20:00:44 +10:00
committed by GitHub
48 changed files with 8663 additions and 3 deletions
+162
View File
@@ -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
View File
@@ -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:
+200
View File
@@ -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
+424
View File
@@ -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>
+73
View File
@@ -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())
}
+28
View File
@@ -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)
}
}
+87
View File
@@ -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
}
}
+62
View File
@@ -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" = "标签已存在";
+165
View File
@@ -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
+380
View File
@@ -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)")
}
}
}
+369
View File
@@ -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)
}
}
+307
View File
@@ -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
+476
View File
@@ -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())
}
+447
View File
@@ -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())
}
+514
View File
@@ -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)
}
+476
View File
@@ -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)
}
}
}
}
+485
View File
@@ -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)
}
+381
View File
@@ -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())
}
+135
View File
@@ -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())
}
+385
View File
@@ -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())
}
+129
View File
@@ -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())
}
+141
View File
@@ -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.
+199
View File
@@ -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]
+76
View File
@@ -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.
+80
View File
@@ -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.
+116
View File
@@ -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
+123
View File
@@ -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
View File
@@ -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