mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
411 lines
16 KiB
Swift
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())
|
|
}
|