Major performance improvements to prevent crashes and lag when working with large label collections: Main App: - Switch to Core Data as primary source for labels (instant loading) - Implement background API sync to keep labels up-to-date - Add LazyVStack for efficient rendering of large label lists - Use batch operations instead of individual queries (1 query vs 1000) - Generate unique IDs for local labels to prevent duplicate warnings Share Extension: - Convert getTags() to async with background context - Add saveTags() method with batch insert support - Load labels from Core Data first, then sync with API - Remove duplicate server reachability checks - Reduce memory usage and prevent UI freezes Technical Details: - Labels now load instantly from local cache - API sync happens in background without blocking UI - Batch fetch operations for optimal database performance - Proper error handling for offline scenarios - Fixed duplicate ID warnings in ForEach loops Fixes crashes and lag reported by users with 1000+ labels.
241 lines
11 KiB
Swift
241 lines
11 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import CoreData
|
|
|
|
class ShareBookmarkViewModel: ObservableObject {
|
|
@Published var url: String?
|
|
@Published var title: String = ""
|
|
@Published var labels: [BookmarkLabelDto] = []
|
|
@Published var selectedLabels: Set<String> = []
|
|
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
|
|
@Published var isSaving: Bool = false
|
|
@Published var searchText: String = ""
|
|
@Published var isServerReachable: Bool = true
|
|
let extensionContext: NSExtensionContext?
|
|
|
|
private let logger = Logger.viewModel
|
|
private let serverCheck = ShareExtensionServerCheck.shared
|
|
|
|
var availableLabels: [BookmarkLabelDto] {
|
|
return labels.filter { !selectedLabels.contains($0.name) }
|
|
}
|
|
|
|
// filtered labels based on search text
|
|
var filteredLabels: [BookmarkLabelDto] {
|
|
if searchText.isEmpty {
|
|
return availableLabels
|
|
} else {
|
|
return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
|
}
|
|
}
|
|
|
|
var availableLabelPages: [[BookmarkLabelDto]] {
|
|
let pageSize = 12 // Extension can't access Constants.Labels.pageSize
|
|
let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels
|
|
|
|
if labelsToShow.count <= pageSize {
|
|
return [labelsToShow]
|
|
} else {
|
|
return stride(from: 0, to: labelsToShow.count, by: pageSize).map {
|
|
Array(labelsToShow[$0..<min($0 + pageSize, labelsToShow.count)])
|
|
}
|
|
}
|
|
}
|
|
|
|
init(extensionContext: NSExtensionContext?) {
|
|
self.extensionContext = extensionContext
|
|
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
|
|
extractSharedContent()
|
|
}
|
|
|
|
func onAppear() {
|
|
logger.debug("ShareBookmarkViewModel appeared")
|
|
loadLabels()
|
|
}
|
|
|
|
private func extractSharedContent() {
|
|
logger.debug("Starting to extract shared content")
|
|
guard let extensionContext = extensionContext else {
|
|
logger.warning("No extension context available for content extraction")
|
|
return
|
|
}
|
|
|
|
var extractedUrl: String?
|
|
var extractedTitle: String?
|
|
|
|
for item in extensionContext.inputItems {
|
|
guard let inputItem = item as? NSExtensionItem else { continue }
|
|
|
|
// Use the inputItem's attributedTitle or attributedContentText as potential title
|
|
if let attributedTitle = inputItem.attributedTitle?.string, !attributedTitle.isEmpty {
|
|
extractedTitle = attributedTitle
|
|
logger.info("Extracted title from input item: \(attributedTitle)")
|
|
} else if let attributedContent = inputItem.attributedContentText?.string, !attributedContent.isEmpty {
|
|
extractedTitle = attributedContent
|
|
logger.info("Extracted title from content text: \(attributedContent)")
|
|
}
|
|
|
|
for attachment in inputItem.attachments ?? [] {
|
|
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
|
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
|
|
DispatchQueue.main.async {
|
|
if let url = url as? URL {
|
|
self?.url = url.absoluteString
|
|
self?.logger.info("Extracted URL from shared content: \(url.absoluteString)")
|
|
|
|
// Set title if we extracted one and current title is empty
|
|
if let title = extractedTitle, self?.title.isEmpty == true {
|
|
self?.title = title
|
|
self?.logger.info("Set title from shared content: \(title)")
|
|
}
|
|
} else if let error = error {
|
|
self?.logger.error("Failed to extract URL: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
|
attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in
|
|
DispatchQueue.main.async {
|
|
if let text = text as? String {
|
|
// Only treat as URL if it's a valid URL and we don't have one yet
|
|
if self?.url == nil, let url = URL(string: text), url.scheme != nil {
|
|
self?.url = url.absoluteString
|
|
self?.logger.info("Extracted URL from shared text: \(url.absoluteString)")
|
|
} else {
|
|
// If not a valid URL or we already have a URL, treat as potential title
|
|
if self?.title.isEmpty == true {
|
|
self?.title = text
|
|
self?.logger.info("Set title from shared text: \(text)")
|
|
}
|
|
}
|
|
} else if let error = error {
|
|
self?.logger.error("Failed to extract text: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadLabels() {
|
|
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
|
|
logger.debug("Starting to load labels")
|
|
Task {
|
|
// 1. First, load from Core Data (instant response)
|
|
let localTags = await OfflineBookmarkManager.shared.getTags()
|
|
let localLabels = localTags.enumerated().map { index, tagName in
|
|
BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)")
|
|
}
|
|
|
|
await MainActor.run {
|
|
self.labels = localLabels
|
|
self.logger.info("Loaded \(localLabels.count) labels from local cache")
|
|
}
|
|
|
|
// 2. Then check server and sync in background
|
|
let serverReachable = await serverCheck.checkServerReachability()
|
|
await MainActor.run {
|
|
self.isServerReachable = serverReachable
|
|
}
|
|
logger.debug("Server reachable for labels: \(serverReachable)")
|
|
|
|
if serverReachable {
|
|
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
|
if error {
|
|
self?.logger.error("Failed to sync labels from API: \(message)")
|
|
}
|
|
} ?? []
|
|
|
|
// Save new labels to Core Data
|
|
let tagNames = loaded.map { $0.name }
|
|
await OfflineBookmarkManager.shared.saveTags(tagNames)
|
|
|
|
let sorted = loaded.sorted { $0.count > $1.count }
|
|
await MainActor.run {
|
|
self.labels = Array(sorted)
|
|
self.logger.info("Synced \(loaded.count) labels from API and updated cache")
|
|
measurement.end()
|
|
}
|
|
} else {
|
|
measurement.end()
|
|
}
|
|
}
|
|
}
|
|
|
|
func save() {
|
|
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
|
|
guard let url = url, !url.isEmpty else {
|
|
logger.warning("Save attempted without valid URL")
|
|
statusMessage = ("No URL found.", true, "❌")
|
|
return
|
|
}
|
|
isSaving = true
|
|
logger.debug("Set saving state to true")
|
|
|
|
// Check server connectivity
|
|
Task {
|
|
let serverReachable = await serverCheck.checkServerReachability()
|
|
logger.debug("Server connectivity for save: \(serverReachable)")
|
|
if serverReachable {
|
|
// Online - try to save via API
|
|
logger.info("Attempting to save bookmark via API")
|
|
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
|
self?.logger.info("API save completed - Success: \(!error), Message: \(message)")
|
|
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
|
self?.isSaving = false
|
|
if !error {
|
|
self?.logger.debug("Bookmark saved successfully, completing extension request")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
self?.completeExtensionRequest()
|
|
}
|
|
} else {
|
|
self?.logger.error("Failed to save bookmark via API: \(message)")
|
|
}
|
|
}
|
|
} else {
|
|
// Server not reachable - save locally
|
|
logger.info("Server not reachable, attempting local save")
|
|
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
|
url: url,
|
|
title: title,
|
|
tags: Array(selectedLabels)
|
|
)
|
|
logger.info("Local save result: \(success)")
|
|
|
|
DispatchQueue.main.async {
|
|
self.isSaving = false
|
|
if success {
|
|
self.logger.info("Bookmark saved locally successfully")
|
|
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
self.completeExtensionRequest()
|
|
}
|
|
} else {
|
|
self.logger.error("Failed to save bookmark locally")
|
|
self.statusMessage = ("Failed to save locally.", true, "❌")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func completeExtensionRequest() {
|
|
logger.debug("Completing extension request")
|
|
guard let context = extensionContext else {
|
|
logger.warning("Extension context not available for completion")
|
|
return
|
|
}
|
|
|
|
context.completeRequest(returningItems: []) { [weak self] error in
|
|
if error {
|
|
self?.logger.error("Extension completion failed: \(error)")
|
|
} else {
|
|
self?.logger.info("Extension request completed successfully")
|
|
}
|
|
}
|
|
}
|
|
}
|