From 0d558354e792a69be8610fbe0432d4fa025e1aaf Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 16 Aug 2025 19:53:36 +1000 Subject: [PATCH] Add links import feature --- links/views.py | 3 +- mobile/Heygo.xcodeproj/project.pbxproj | 4 + mobile/Heygo/ContentView.swift | 8 +- .../Resources/en.lproj/Localizable.strings | 23 ++ .../zh-Hans.lproj/Localizable.strings | 23 ++ mobile/Heygo/Views/SettingsView.swift | 381 ++++++++++++++++++ run_server.sh | 2 +- 7 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 mobile/Heygo/Views/SettingsView.swift diff --git a/links/views.py b/links/views.py index 7cb0044..7d07eb6 100644 --- a/links/views.py +++ b/links/views.py @@ -396,8 +396,7 @@ class ToolsView(View): template_name = 'links/tools.html' def get(self, request): - export_url = reverse('export_links') - return render(request, self.template_name, {'export_url': export_url}) + return render(request, self.template_name) def post(self, request): if 'export' in request.POST: diff --git a/mobile/Heygo.xcodeproj/project.pbxproj b/mobile/Heygo.xcodeproj/project.pbxproj index 66246ed..42c2cba 100644 --- a/mobile/Heygo.xcodeproj/project.pbxproj +++ b/mobile/Heygo.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 35CECEB72E503305000D0A0A /* SpotlightSearchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEB62E503305000D0A0A /* SpotlightSearchManager.swift */; }; 35CECEB92E506588000D0A0A /* TagDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEB82E506588000D0A0A /* TagDetailView.swift */; }; 35CECEBB2E5065AB000D0A0A /* TagEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEBA2E5065AB000D0A0A /* TagEditView.swift */; }; + 35CECEBD2E507F51000D0A0A /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CECEBC2E507F51000D0A0A /* SettingsView.swift */; }; B001 /* HeygoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* HeygoApp.swift */; }; B002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* ContentView.swift */; }; B003 /* LinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* LinkListView.swift */; }; @@ -36,6 +37,7 @@ 35CECEB62E503305000D0A0A /* SpotlightSearchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightSearchManager.swift; sourceTree = ""; }; 35CECEB82E506588000D0A0A /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = ""; }; 35CECEBA2E5065AB000D0A0A /* TagEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagEditView.swift; sourceTree = ""; }; + 35CECEBC2E507F51000D0A0A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; F001 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = ""; }; F002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; F003 /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = ""; }; @@ -139,6 +141,7 @@ GVIEW /* Views */ = { isa = PBXGroup; children = ( + 35CECEBC2E507F51000D0A0A /* SettingsView.swift */, 35CECEBA2E5065AB000D0A0A /* TagEditView.swift */, 35CECEB82E506588000D0A0A /* TagDetailView.swift */, F003 /* LinkListView.swift */, @@ -243,6 +246,7 @@ B010 /* ChangeLog+CoreDataClass.swift in Sources */, B013 /* PersistenceController.swift in Sources */, B014 /* CoreDataModel.xcdatamodeld in Sources */, + 35CECEBD2E507F51000D0A0A /* SettingsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/mobile/Heygo/ContentView.swift b/mobile/Heygo/ContentView.swift index 8d8f7bf..26bae23 100644 --- a/mobile/Heygo/ContentView.swift +++ b/mobile/Heygo/ContentView.swift @@ -24,10 +24,16 @@ struct ContentView: View { Text("analytics") } + SettingsView() + .tabItem { + Image(systemName: "gear") + Text("settings") + } + #if DEBUG SpotlightDebugView() .tabItem { - Image(systemName: "gear") + Image(systemName: "eye") Text("spotlight") } #endif diff --git a/mobile/Heygo/Resources/en.lproj/Localizable.strings b/mobile/Heygo/Resources/en.lproj/Localizable.strings index 3598173..fc63cf5 100644 --- a/mobile/Heygo/Resources/en.lproj/Localizable.strings +++ b/mobile/Heygo/Resources/en.lproj/Localizable.strings @@ -163,3 +163,26 @@ /* Basic Information */ "basic_information" = "Basic Information"; "actions" = "Actions"; + +/* Settings */ +"settings" = "Settings"; +"change_language" = "Change Language"; +"import_links" = "Import Links"; +"system_language" = "System Language"; +"english" = "English"; +"chinese_simplified" = "Chinese (Simplified)"; + +/* Import */ +"import_result" = "Import Result"; +"import_success" = "%d links imported successfully!"; +"import_success_with_skipped" = "%d links imported successfully! %d were skipped (already exist)."; +"import_skipped" = "%d entries were skipped (already exist)."; +"import_failed" = "%d entries failed to import."; +"import_error" = "Import failed: %@"; +"import_parse_error" = "Failed to parse JSON file: %@"; +"file_access_error" = "Unable to access the selected file"; +"ok" = "OK"; + +/* Language Change */ +"language_changed" = "Language Changed"; +"restart_app_message" = "Please close and restart the app for the language change to take effect."; diff --git a/mobile/Heygo/Resources/zh-Hans.lproj/Localizable.strings b/mobile/Heygo/Resources/zh-Hans.lproj/Localizable.strings index 6da33ff..fe3e8a8 100644 --- a/mobile/Heygo/Resources/zh-Hans.lproj/Localizable.strings +++ b/mobile/Heygo/Resources/zh-Hans.lproj/Localizable.strings @@ -162,6 +162,29 @@ "basic_information" = "基本信息"; "actions" = "操作"; +/* Settings */ +"settings" = "设置"; +"change_language" = "更改语言"; +"import_links" = "导入链接"; +"system_language" = "系统语言"; +"english" = "英语"; +"chinese_simplified" = "简体中文"; + +/* Import */ +"import_result" = "导入结果"; +"import_success" = "成功导入 %d 个链接!"; +"import_success_with_skipped" = "成功导入 %d 个链接!%d 个已跳过(已存在)。"; +"import_skipped" = "%d 个条目已跳过(已存在)。"; +"import_failed" = "%d 个条目导入失败。"; +"import_error" = "导入失败:%@"; +"import_parse_error" = "解析 JSON 文件失败:%@"; +"file_access_error" = "无法访问所选文件"; +"ok" = "确定"; + +/* Language Change */ +"language_changed" = "语言已更改"; +"restart_app_message" = "请关闭并重新启动应用程序以使语言更改生效。"; + /* Validation Messages */ "alias_already_exists" = "别名已存在"; "alias_invalid_characters" = "别名包含无效字符"; diff --git a/mobile/Heygo/Views/SettingsView.swift b/mobile/Heygo/Views/SettingsView.swift new file mode 100644 index 0000000..b8add70 --- /dev/null +++ b/mobile/Heygo/Views/SettingsView.swift @@ -0,0 +1,381 @@ +import SwiftUI +import UniformTypeIdentifiers +import CoreData + +struct SettingsView: View { + @State private var showingImportPicker = false + @State private var showingImportResult = false + @State private var importResultMessage = "" + @State private var importedCount = 0 + @State private var isImporting = false + @EnvironmentObject var linkViewModel: LinkViewModel + + var body: some View { + NavigationView { + List { + Section { + NavigationLink(destination: LanguageSettingsView()) { + Label("change_language", systemImage: "globe") + } + + Button(action: { + showingImportPicker = true + }) { + HStack { + Label("import_links", systemImage: "square.and.arrow.down") + .foregroundColor(.primary) + Spacer() + if isImporting { + ProgressView() + .scaleEffect(0.8) + } + } + } + .disabled(isImporting) + } + } + .navigationTitle("settings") + .fileImporter( + isPresented: $showingImportPicker, + allowedContentTypes: [UTType.json], + allowsMultipleSelection: false + ) { result in + handleImportResult(result) + } + .alert("import_result", isPresented: $showingImportResult) { + Button("ok") { } + } message: { + Text(importResultMessage) + } + } + } + + private func handleImportResult(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + importLinks(from: url) + case .failure(let error): + importResultMessage = String(format: NSLocalizedString("import_error", comment: ""), error.localizedDescription) + showingImportResult = true + } + } + + private func importLinks(from url: URL) { + isImporting = true + + guard url.startAccessingSecurityScopedResource() else { + importResultMessage = NSLocalizedString("file_access_error", comment: "") + showingImportResult = true + isImporting = false + return + } + + defer { url.stopAccessingSecurityScopedResource() } + + do { + let data = try Data(contentsOf: url) + + let context = PersistenceController.shared.container.viewContext + var importedCount = 0 + var skippedCount = 0 + var failedEntries: [String] = [] + + // First try to parse the entire JSON as LinkData array + do { + let linkDataArray = try JSONDecoder().decode([LinkData].self, from: data) + + // If successful, process normally + for linkData in linkDataArray { + do { + // Check if link with same alias already exists + let fetchRequest: NSFetchRequest = Link.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "alias == %@", linkData.alias.lowercased()) + + if try context.count(for: fetchRequest) == 0 { + // Create new link + let link = Link(context: context) + link.alias = linkData.alias.lowercased() + link.originalURL = linkData.originalUrl + link.linkType = linkData.linkType ?? "LINK" + link.text = linkData.text + link.createdAt = linkData.createdAt ?? Date() + link.updatedAt = linkData.updatedAt ?? Date() + link.clickCount = 0 + + // Handle tags if present + if let tagNames = linkData.tags { + for tagName in tagNames { + let tagFetchRequest: NSFetchRequest = Tag.fetchRequest() + tagFetchRequest.predicate = NSPredicate(format: "name == %@", tagName) + + var tag: Tag + if let existingTag = try context.fetch(tagFetchRequest).first { + tag = existingTag + } else { + tag = Tag(context: context) + tag.name = tagName + // Generate slug (required non-optional Core Data attribute) + tag.slug = tagName + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: "-") + .replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression) + tag.createdAt = Date() + } + link.addToTags(tag) + } + } + + importedCount += 1 + } else { + skippedCount += 1 + } + } catch { + // If individual processing fails, add to failed entries + failedEntries.append(linkData.alias) + print("Failed to process entry '\(linkData.alias)': \(error.localizedDescription)") + } + } + + } catch { + // If parsing entire array fails, try individual entry parsing + print("Failed to parse as LinkData array, trying individual parsing: \(error)") + + guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { + throw NSError(domain: "ImportError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format - not an array"]) + } + + for (index, jsonEntry) in jsonArray.enumerated() { + do { + // Try to parse individual entry + let entryData = try JSONSerialization.data(withJSONObject: jsonEntry, options: []) + let linkData = try JSONDecoder().decode(LinkData.self, from: entryData) + + // Check if link with same alias already exists + let fetchRequest: NSFetchRequest = Link.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "alias == %@", linkData.alias.lowercased()) + + if try context.count(for: fetchRequest) == 0 { + // Create new link + let link = Link(context: context) + link.alias = linkData.alias.lowercased() + link.originalURL = linkData.originalUrl + link.linkType = linkData.linkType ?? "LINK" + link.text = linkData.text + link.createdAt = linkData.createdAt ?? Date() + link.updatedAt = linkData.updatedAt ?? Date() + link.clickCount = 0 + + // Handle tags if present + if let tagNames = linkData.tags { + for tagName in tagNames { + let tagFetchRequest: NSFetchRequest = Tag.fetchRequest() + tagFetchRequest.predicate = NSPredicate(format: "name == %@", tagName) + + var tag: Tag + if let existingTag = try context.fetch(tagFetchRequest).first { + tag = existingTag + } else { + tag = Tag(context: context) + tag.name = tagName + // Generate slug (required non-optional Core Data attribute) + tag.slug = tagName + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: "-") + .replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression) + tag.createdAt = Date() + } + link.addToTags(tag) + } + } + + importedCount += 1 + } else { + skippedCount += 1 + } + } catch { + // If individual entry fails, collect the alias and continue + let alias = jsonEntry["alias"] as? String ?? "Entry #\(index + 1)" + failedEntries.append(alias) + print("Failed to import entry '\(alias)': \(error.localizedDescription)") + } + } + } + + try context.save() + + // Refresh the link view model + linkViewModel.fetchLinks() + + self.importedCount = importedCount + + // Build result message + var resultParts: [String] = [] + + if importedCount > 0 { + resultParts.append(String(format: NSLocalizedString("import_success", comment: ""), importedCount)) + } + + if skippedCount > 0 { + resultParts.append(String(format: NSLocalizedString("import_skipped", comment: ""), skippedCount)) + } + + if !failedEntries.isEmpty { + resultParts.append(String(format: NSLocalizedString("import_failed", comment: ""), failedEntries.count)) + resultParts.append("Failed entries: \(failedEntries.joined(separator: ", "))") + } + + importResultMessage = resultParts.joined(separator: "\n") + showingImportResult = true + + } catch { + importResultMessage = String(format: NSLocalizedString("import_parse_error", comment: ""), error.localizedDescription) + showingImportResult = true + } + + isImporting = false + } +} + +struct LanguageSettingsView: View { + @AppStorage("selectedLanguage") private var selectedLanguage = "system" + @State private var showingRestartAlert = false + + private let languages = [ + ("system", "system_language", "🌐"), + ("en", "english", "🇺🇸"), + ("zh-Hans", "chinese_simplified", "🇨🇳") + ] + + var body: some View { + List { + ForEach(languages, id: \.0) { code, nameKey, flag in + Button(action: { + let previousLanguage = selectedLanguage + selectedLanguage = code + setLanguage(code) + + // Show restart alert if language actually changed + if previousLanguage != code { + showingRestartAlert = true + } + }) { + HStack { + Text(flag) + Text(NSLocalizedString(nameKey, comment: "")) + .foregroundColor(.primary) + Spacer() + if selectedLanguage == code { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } + .navigationTitle("change_language") + .navigationBarTitleDisplayMode(.inline) + .alert("language_changed", isPresented: $showingRestartAlert) { + Button("ok") { } + } message: { + Text("restart_app_message") + } + } + + private func setLanguage(_ languageCode: String) { + if languageCode == "system" { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } else { + UserDefaults.standard.set([languageCode], forKey: "AppleLanguages") + } + + // Note: Language change will take effect after app restart + // You could show an alert informing the user about this + } +} + +// Data structure for importing links +struct LinkData: Codable { + let alias: String + let originalUrl: String? + let linkType: String? + let text: String? + let createdAt: Date? + let updatedAt: Date? + let tags: [String]? + + enum CodingKeys: String, CodingKey { + case alias + case originalUrl = "original_url" + case linkType = "link_type" + case text + case createdAt = "created_at" + case updatedAt = "updated_at" + case tags + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + alias = try container.decode(String.self, forKey: .alias) + originalUrl = try container.decodeIfPresent(String.self, forKey: .originalUrl) + linkType = try container.decodeIfPresent(String.self, forKey: .linkType) + text = try container.decodeIfPresent(String.self, forKey: .text) + tags = try container.decodeIfPresent([String].self, forKey: .tags) + + // Handle date parsing with multiple formatters + if let createdAtString = try container.decodeIfPresent(String.self, forKey: .createdAt) { + createdAt = Self.parseDate(from: createdAtString) + } else { + createdAt = nil + } + + if let updatedAtString = try container.decodeIfPresent(String.self, forKey: .updatedAt) { + updatedAt = Self.parseDate(from: updatedAtString) + } else { + updatedAt = nil + } + } + + private static func parseDate(from dateString: String) -> Date? { + // Try ISO8601 formatters first + let iso8601Formatter1 = ISO8601DateFormatter() + + let iso8601Formatter2 = ISO8601DateFormatter() + iso8601Formatter2.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let iso8601Formatters = [iso8601Formatter1, iso8601Formatter2] + + for formatter in iso8601Formatters { + if let date = formatter.date(from: dateString) { + return date + } + } + + // Try custom DateFormatter patterns + let dateFormatter1 = DateFormatter() + dateFormatter1.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZZZZZ" + + let dateFormatter2 = DateFormatter() + dateFormatter2.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + + let dateFormatter3 = DateFormatter() + dateFormatter3.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + + let dateFormatters = [dateFormatter1, dateFormatter2, dateFormatter3] + + for formatter in dateFormatters { + if let date = formatter.date(from: dateString) { + return date + } + } + + return nil + } +} + +#Preview { + SettingsView() + .environmentObject(LinkViewModel()) +} diff --git a/run_server.sh b/run_server.sh index 860513e..00b16f2 100755 --- a/run_server.sh +++ b/run_server.sh @@ -2,4 +2,4 @@ set -eu source .venv/bin/activate echo "Starting Django development server..." -python manage.py runserver +python manage.py runserver 0.0.0.0:8000