mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add links import feature
This commit is contained in:
+1
-2
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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" = "别名包含无效字符";
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user