Files
links/mobile/Heygo/Views/SettingsView.swift
T
2025-08-16 20:24:00 +10:00

411 lines
16 KiB
Swift

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] = []
// Helper to generate slug similar to backend slugify
func generateSlug(_ raw: String) -> String {
let base = raw
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression)
return base
}
// Fetch or create tag ensuring unique slug
func fetchOrCreateTag(named name: String) throws -> Tag {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { throw NSError(domain: "Import", code: 10, userInfo: [NSLocalizedDescriptionKey: "Empty tag name"]) }
// Case-insensitive fetch
let request: NSFetchRequest<Tag> = Tag.fetchRequest()
request.predicate = NSPredicate(format: "name ==[c] %@", trimmed)
request.fetchLimit = 1
if let existing = try context.fetch(request).first { return existing }
// Create new tag with unique slug
let tag = Tag(context: context)
tag.name = trimmed
var slug = generateSlug(trimmed)
if slug.isEmpty { slug = UUID().uuidString.prefix(8).lowercased() }
// Ensure slug uniqueness
var uniqueSlug = slug
var index = 1
while true {
let slugRequest: NSFetchRequest<Tag> = Tag.fetchRequest()
slugRequest.predicate = NSPredicate(format: "slug == %@", uniqueSlug)
slugRequest.fetchLimit = 1
if try context.count(for: slugRequest) == 0 { break }
uniqueSlug = "\(slug)-\(index)"
index += 1
}
tag.slug = uniqueSlug
tag.createdAt = Date()
return tag
}
// First try to parse the entire JSON as LinkData array
do {
let linkDataArray = try JSONDecoder().decode([LinkData].self, from: data)
// If successful, process normally
for linkData in linkDataArray {
do {
// Check if link with same alias already exists
let fetchRequest: NSFetchRequest<Link> = Link.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "alias == %@", linkData.alias.lowercased())
if try context.count(for: fetchRequest) == 0 {
// Create new link
let link = Link(context: context)
link.alias = linkData.alias.lowercased()
link.originalURL = linkData.originalUrl
link.linkType = linkData.linkType ?? "LINK"
link.text = linkData.text
link.createdAt = linkData.createdAt ?? Date()
link.updatedAt = linkData.updatedAt ?? Date()
link.clickCount = 0
// Handle tags if present
if let tagNames = linkData.tags {
for tagName in tagNames {
do {
let tag = try fetchOrCreateTag(named: tagName)
link.addToTags(tag)
} catch {
print("⚠️ Skipped tag '\(tagName)': \(error.localizedDescription)")
}
}
}
importedCount += 1
} else {
skippedCount += 1
}
} catch {
// If individual processing fails, add to failed entries
failedEntries.append(linkData.alias)
print("Failed to process entry '\(linkData.alias)': \(error.localizedDescription)")
}
}
} catch {
// If parsing entire array fails, try individual entry parsing
print("Failed to parse as LinkData array, trying individual parsing: \(error)")
guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else {
throw NSError(domain: "ImportError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format - not an array"])
}
for (index, jsonEntry) in jsonArray.enumerated() {
do {
// Try to parse individual entry
let entryData = try JSONSerialization.data(withJSONObject: jsonEntry, options: [])
let linkData = try JSONDecoder().decode(LinkData.self, from: entryData)
// Check if link with same alias already exists
let fetchRequest: NSFetchRequest<Link> = Link.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "alias == %@", linkData.alias.lowercased())
if try context.count(for: fetchRequest) == 0 {
// Create new link
let link = Link(context: context)
link.alias = linkData.alias.lowercased()
link.originalURL = linkData.originalUrl
link.linkType = linkData.linkType ?? "LINK"
link.text = linkData.text
link.createdAt = linkData.createdAt ?? Date()
link.updatedAt = linkData.updatedAt ?? Date()
link.clickCount = 0
// Handle tags if present
if let tagNames = linkData.tags {
for tagName in tagNames {
do {
let tag = try fetchOrCreateTag(named: tagName)
link.addToTags(tag)
} catch {
print("⚠️ Skipped tag '\(tagName)': \(error.localizedDescription)")
}
}
}
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)")
}
}
}
do {
try context.save()
} catch {
let nsError = error as NSError
if let detailed = nsError.userInfo[NSDetailedErrorsKey] as? [NSError] {
for d in detailed { print("❌ Save detail: \(d), userInfo: \(d.userInfo)") }
} else {
print("❌ Save error: \(nsError), userInfo: \(nsError.userInfo)")
}
throw nsError
}
// 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())
}