Add init working mobile app

This commit is contained in:
2025-08-16 11:13:00 +10:00
parent baf4f37f69
commit 409662f7f5
35 changed files with 6044 additions and 0 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.
+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
+408
View File
@@ -0,0 +1,408 @@
// !$*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 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 */
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 = (
F009 /* TagManager.swift */,
F00A /* URLTemplateProcessor.swift */,
F00B /* MarkdownRenderer.swift */,
F00C /* AnalyticsCalculator.swift */,
);
path = Utils;
sourceTree = "<group>";
};
GVIEW /* Views */ = {
isa = PBXGroup;
children = (
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 */,
B004 /* LinkDetailView.swift in Sources */,
B005 /* LinkFormView.swift in Sources */,
B006 /* AnalyticsView.swift in Sources */,
B007 /* SearchView.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 */,
B010 /* ChangeLog+CoreDataClass.swift in Sources */,
B013 /* PersistenceController.swift in Sources */,
B014 /* CoreDataModel.xcdatamodeld 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>
+34
View File
@@ -0,0 +1,34 @@
import SwiftUI
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@StateObject private 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")
}
}
.environmentObject(linkViewModel)
}
}
#Preview {
ContentView()
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
+13
View File
@@ -0,0 +1,13 @@
import SwiftUI
@main
struct HeygoApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
@@ -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,142 @@
/* 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";
/* Tags */
"tags" = "Tags";
"search_or_create_tag" = "Search or create tag";
"selected_tag" = "Selected Tag";
"all_tags" = "All Tags";
"popular_tags" = "Popular Tags";
/* 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";
"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";
"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";
@@ -0,0 +1,142 @@
/* 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=值} 创建模板";
/* Tags */
"tags" = "标签";
"search_or_create_tag" = "搜索或创建标签";
"selected_tag" = "已选标签";
"all_tags" = "全部标签";
"popular_tags" = "热门标签";
/* 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" = "图表数据点";
/* Basic Information */
"basic_information" = "基本信息";
"actions" = "操作";
@@ -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")
}
}
}
+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,149 @@
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 {
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] = []
let pattern = "\\{([^,}]+)(?:,default=([^}]+))?\\}"
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])
var defaultValue: String? = nil
if defaultRange.location != NSNotFound,
let defaultSwiftRange = Range(defaultRange, in: template) {
defaultValue = String(template[defaultSwiftRange])
}
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)
}
}
+272
View File
@@ -0,0 +1,272 @@
import Foundation
import CoreData
import Combine
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()
}
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()
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()
fetchLinks()
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
func deleteLink(_ link: Link) {
let context = persistenceController.container.viewContext
context.delete(link)
do {
try context.save()
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())
}
+469
View File
@@ -0,0 +1,469 @@
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
recentClicksSection
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
Text(tag.name)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.2))
.foregroundColor(.blue)
.cornerRadius(6)
}
}
.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 recentClicksSection: some View {
Group {
if !filteredClickLogs.isEmpty {
VStack(alignment: .leading) {
Text("recent_clicks")
.font(.headline)
ForEach(filteredClickLogs.prefix(10), id: \.id) { clickLog in
HStack {
Image(systemName: "hand.tap")
.foregroundColor(.blue)
Text(clickLog.clickedAt, format: .dateTime)
.font(.body)
Spacer()
}
.padding(.vertical, 2)
}
}
}
}
}
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())
}
+308
View File
@@ -0,0 +1,308 @@
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 showingTagPicker: 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
}
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())
// 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)
if selectedLinkType == .template {
Text("template_help")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
// 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()
}
}
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
}
}
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 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() {
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()
}
}
}
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)
}
+364
View File
@@ -0,0 +1,364 @@
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?
var body: some View {
NavigationView {
VStack {
// Test UI
Text("Heygo Links")
.font(.title)
.padding()
// 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)
// 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)
.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("heygo")
.navigationBarTitleDisplayMode(.large)
.toolbar {
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)
}
}
.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
@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: {
if link.isTemplate {
// Handle template parameters
// For now, just open without parameters
linkViewModel.openLink(link)
} else {
linkViewModel.openLink(link)
}
}) {
Image(systemName: link.isTemplate ? "arrow.right.circle.fill" : "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
}
}
}
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)
}
+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)
}
+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]