Add links import feature

This commit is contained in:
2025-08-16 19:53:36 +10:00
parent 32e08dae43
commit 0d558354e7
7 changed files with 440 additions and 4 deletions
+4
View File
@@ -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 = "<group>"; };
35CECEB82E506588000D0A0A /* TagDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailView.swift; sourceTree = "<group>"; };
35CECEBA2E5065AB000D0A0A /* TagEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagEditView.swift; sourceTree = "<group>"; };
35CECEBC2E507F51000D0A0A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
F001 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = "<group>"; };
F002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
F003 /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = "<group>"; };
@@ -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;
};
+7 -1
View File
@@ -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
@@ -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.";
@@ -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" = "别名包含无效字符";
+381
View File
@@ -0,0 +1,381 @@
import SwiftUI
import UniformTypeIdentifiers
import CoreData
struct SettingsView: View {
@State private var showingImportPicker = false
@State private var showingImportResult = false
@State private var importResultMessage = ""
@State private var importedCount = 0
@State private var isImporting = false
@EnvironmentObject var linkViewModel: LinkViewModel
var body: some View {
NavigationView {
List {
Section {
NavigationLink(destination: LanguageSettingsView()) {
Label("change_language", systemImage: "globe")
}
Button(action: {
showingImportPicker = true
}) {
HStack {
Label("import_links", systemImage: "square.and.arrow.down")
.foregroundColor(.primary)
Spacer()
if isImporting {
ProgressView()
.scaleEffect(0.8)
}
}
}
.disabled(isImporting)
}
}
.navigationTitle("settings")
.fileImporter(
isPresented: $showingImportPicker,
allowedContentTypes: [UTType.json],
allowsMultipleSelection: false
) { result in
handleImportResult(result)
}
.alert("import_result", isPresented: $showingImportResult) {
Button("ok") { }
} message: {
Text(importResultMessage)
}
}
}
private func handleImportResult(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
importLinks(from: url)
case .failure(let error):
importResultMessage = String(format: NSLocalizedString("import_error", comment: ""), error.localizedDescription)
showingImportResult = true
}
}
private func importLinks(from url: URL) {
isImporting = true
guard url.startAccessingSecurityScopedResource() else {
importResultMessage = NSLocalizedString("file_access_error", comment: "")
showingImportResult = true
isImporting = false
return
}
defer { url.stopAccessingSecurityScopedResource() }
do {
let data = try Data(contentsOf: url)
let context = PersistenceController.shared.container.viewContext
var importedCount = 0
var skippedCount = 0
var failedEntries: [String] = []
// First try to parse the entire JSON as LinkData array
do {
let linkDataArray = try JSONDecoder().decode([LinkData].self, from: data)
// If successful, process normally
for linkData in linkDataArray {
do {
// Check if link with same alias already exists
let fetchRequest: NSFetchRequest<Link> = Link.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "alias == %@", linkData.alias.lowercased())
if try context.count(for: fetchRequest) == 0 {
// Create new link
let link = Link(context: context)
link.alias = linkData.alias.lowercased()
link.originalURL = linkData.originalUrl
link.linkType = linkData.linkType ?? "LINK"
link.text = linkData.text
link.createdAt = linkData.createdAt ?? Date()
link.updatedAt = linkData.updatedAt ?? Date()
link.clickCount = 0
// Handle tags if present
if let tagNames = linkData.tags {
for tagName in tagNames {
let tagFetchRequest: NSFetchRequest<Tag> = Tag.fetchRequest()
tagFetchRequest.predicate = NSPredicate(format: "name == %@", tagName)
var tag: Tag
if let existingTag = try context.fetch(tagFetchRequest).first {
tag = existingTag
} else {
tag = Tag(context: context)
tag.name = tagName
// Generate slug (required non-optional Core Data attribute)
tag.slug = tagName
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression)
tag.createdAt = Date()
}
link.addToTags(tag)
}
}
importedCount += 1
} else {
skippedCount += 1
}
} catch {
// If individual processing fails, add to failed entries
failedEntries.append(linkData.alias)
print("Failed to process entry '\(linkData.alias)': \(error.localizedDescription)")
}
}
} catch {
// If parsing entire array fails, try individual entry parsing
print("Failed to parse as LinkData array, trying individual parsing: \(error)")
guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else {
throw NSError(domain: "ImportError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format - not an array"])
}
for (index, jsonEntry) in jsonArray.enumerated() {
do {
// Try to parse individual entry
let entryData = try JSONSerialization.data(withJSONObject: jsonEntry, options: [])
let linkData = try JSONDecoder().decode(LinkData.self, from: entryData)
// Check if link with same alias already exists
let fetchRequest: NSFetchRequest<Link> = Link.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "alias == %@", linkData.alias.lowercased())
if try context.count(for: fetchRequest) == 0 {
// Create new link
let link = Link(context: context)
link.alias = linkData.alias.lowercased()
link.originalURL = linkData.originalUrl
link.linkType = linkData.linkType ?? "LINK"
link.text = linkData.text
link.createdAt = linkData.createdAt ?? Date()
link.updatedAt = linkData.updatedAt ?? Date()
link.clickCount = 0
// Handle tags if present
if let tagNames = linkData.tags {
for tagName in tagNames {
let tagFetchRequest: NSFetchRequest<Tag> = Tag.fetchRequest()
tagFetchRequest.predicate = NSPredicate(format: "name == %@", tagName)
var tag: Tag
if let existingTag = try context.fetch(tagFetchRequest).first {
tag = existingTag
} else {
tag = Tag(context: context)
tag.name = tagName
// Generate slug (required non-optional Core Data attribute)
tag.slug = tagName
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression)
tag.createdAt = Date()
}
link.addToTags(tag)
}
}
importedCount += 1
} else {
skippedCount += 1
}
} catch {
// If individual entry fails, collect the alias and continue
let alias = jsonEntry["alias"] as? String ?? "Entry #\(index + 1)"
failedEntries.append(alias)
print("Failed to import entry '\(alias)': \(error.localizedDescription)")
}
}
}
try context.save()
// Refresh the link view model
linkViewModel.fetchLinks()
self.importedCount = importedCount
// Build result message
var resultParts: [String] = []
if importedCount > 0 {
resultParts.append(String(format: NSLocalizedString("import_success", comment: ""), importedCount))
}
if skippedCount > 0 {
resultParts.append(String(format: NSLocalizedString("import_skipped", comment: ""), skippedCount))
}
if !failedEntries.isEmpty {
resultParts.append(String(format: NSLocalizedString("import_failed", comment: ""), failedEntries.count))
resultParts.append("Failed entries: \(failedEntries.joined(separator: ", "))")
}
importResultMessage = resultParts.joined(separator: "\n")
showingImportResult = true
} catch {
importResultMessage = String(format: NSLocalizedString("import_parse_error", comment: ""), error.localizedDescription)
showingImportResult = true
}
isImporting = false
}
}
struct LanguageSettingsView: View {
@AppStorage("selectedLanguage") private var selectedLanguage = "system"
@State private var showingRestartAlert = false
private let languages = [
("system", "system_language", "🌐"),
("en", "english", "🇺🇸"),
("zh-Hans", "chinese_simplified", "🇨🇳")
]
var body: some View {
List {
ForEach(languages, id: \.0) { code, nameKey, flag in
Button(action: {
let previousLanguage = selectedLanguage
selectedLanguage = code
setLanguage(code)
// Show restart alert if language actually changed
if previousLanguage != code {
showingRestartAlert = true
}
}) {
HStack {
Text(flag)
Text(NSLocalizedString(nameKey, comment: ""))
.foregroundColor(.primary)
Spacer()
if selectedLanguage == code {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
.navigationTitle("change_language")
.navigationBarTitleDisplayMode(.inline)
.alert("language_changed", isPresented: $showingRestartAlert) {
Button("ok") { }
} message: {
Text("restart_app_message")
}
}
private func setLanguage(_ languageCode: String) {
if languageCode == "system" {
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
} else {
UserDefaults.standard.set([languageCode], forKey: "AppleLanguages")
}
// Note: Language change will take effect after app restart
// You could show an alert informing the user about this
}
}
// Data structure for importing links
struct LinkData: Codable {
let alias: String
let originalUrl: String?
let linkType: String?
let text: String?
let createdAt: Date?
let updatedAt: Date?
let tags: [String]?
enum CodingKeys: String, CodingKey {
case alias
case originalUrl = "original_url"
case linkType = "link_type"
case text
case createdAt = "created_at"
case updatedAt = "updated_at"
case tags
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
alias = try container.decode(String.self, forKey: .alias)
originalUrl = try container.decodeIfPresent(String.self, forKey: .originalUrl)
linkType = try container.decodeIfPresent(String.self, forKey: .linkType)
text = try container.decodeIfPresent(String.self, forKey: .text)
tags = try container.decodeIfPresent([String].self, forKey: .tags)
// Handle date parsing with multiple formatters
if let createdAtString = try container.decodeIfPresent(String.self, forKey: .createdAt) {
createdAt = Self.parseDate(from: createdAtString)
} else {
createdAt = nil
}
if let updatedAtString = try container.decodeIfPresent(String.self, forKey: .updatedAt) {
updatedAt = Self.parseDate(from: updatedAtString)
} else {
updatedAt = nil
}
}
private static func parseDate(from dateString: String) -> Date? {
// Try ISO8601 formatters first
let iso8601Formatter1 = ISO8601DateFormatter()
let iso8601Formatter2 = ISO8601DateFormatter()
iso8601Formatter2.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let iso8601Formatters = [iso8601Formatter1, iso8601Formatter2]
for formatter in iso8601Formatters {
if let date = formatter.date(from: dateString) {
return date
}
}
// Try custom DateFormatter patterns
let dateFormatter1 = DateFormatter()
dateFormatter1.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZZZZZ"
let dateFormatter2 = DateFormatter()
dateFormatter2.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
let dateFormatter3 = DateFormatter()
dateFormatter3.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
let dateFormatters = [dateFormatter1, dateFormatter2, dateFormatter3]
for formatter in dateFormatters {
if let date = formatter.date(from: dateString) {
return date
}
}
return nil
}
}
#Preview {
SettingsView()
.environmentObject(LinkViewModel())
}