Refactor tag management system with improved search and real-time sync
- Add CreateLabelUseCase for consistent label creation across app and extension - Implement TagRepository for Share Extension to persist new labels to Core Data - Enhance CoreDataTagManagementView with real-time search functionality - Add automatic tag synchronization on app startup and resume - Improve Core Data context configuration for better extension support - Unify label terminology across UI components (tags -> labels) - Fix label persistence issues in Share Extension - Add immediate Core Data persistence for newly created labels - Bump version to 36
This commit is contained in:
parent
4134b41be2
commit
a3b3863fa3
@ -142,7 +142,8 @@ struct ShareBookmarkView: View {
|
||||
searchFieldFocus: $focusedField,
|
||||
fetchLimit: 150,
|
||||
sortOrder: viewModel.tagSortOrder,
|
||||
availableTagsTitle: "Most used tags",
|
||||
availableLabelsTitle: "Most used labels",
|
||||
context: viewContext,
|
||||
onAddCustomTag: {
|
||||
addCustomTag()
|
||||
},
|
||||
@ -200,19 +201,6 @@ struct ShareBookmarkView: View {
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func addCustomTag() {
|
||||
let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText)
|
||||
|
||||
// Fetch available labels from Core Data
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
let availableLabels = (try? viewContext.fetch(fetchRequest))?.compactMap { $0.name } ?? []
|
||||
|
||||
let currentLabels = Array(viewModel.selectedLabels)
|
||||
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
|
||||
|
||||
for label in uniqueLabels {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
}
|
||||
|
||||
viewModel.searchText = ""
|
||||
viewModel.addCustomTag(context: viewContext)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
|
||||
private let logger = Logger.viewModel
|
||||
private let serverCheck = ShareExtensionServerCheck.shared
|
||||
private let tagRepository = TagRepository()
|
||||
|
||||
init(extensionContext: NSExtensionContext?) {
|
||||
self.extensionContext = extensionContext
|
||||
@ -149,14 +150,37 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func addCustomTag(context: NSManagedObjectContext) {
|
||||
let splitLabels = LabelUtils.splitLabelsFromInput(searchText)
|
||||
|
||||
// Fetch available labels from Core Data
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
let availableLabels = (try? context.fetch(fetchRequest))?.compactMap { $0.name } ?? []
|
||||
|
||||
let currentLabels = Array(selectedLabels)
|
||||
let uniqueLabels = LabelUtils.filterUniqueLabels(splitLabels, currentLabels: currentLabels, availableLabels: availableLabels)
|
||||
|
||||
for label in uniqueLabels {
|
||||
selectedLabels.insert(label)
|
||||
// Save new label to Core Data so it's available next time
|
||||
tagRepository.saveNewLabel(name: label, context: context)
|
||||
}
|
||||
|
||||
// Force refresh of @FetchRequest in CoreDataTagManagementView
|
||||
// This ensures newly created labels appear immediately in the search results
|
||||
context.refreshAllObjects()
|
||||
|
||||
searchText = ""
|
||||
}
|
||||
|
||||
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)")
|
||||
|
||||
63
URLShare/TagRepository.swift
Normal file
63
URLShare/TagRepository.swift
Normal file
@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Simple repository for managing tags in Share Extension
|
||||
class TagRepository {
|
||||
|
||||
private let logger = Logger.data
|
||||
|
||||
/// Saves a new label to Core Data if it doesn't already exist
|
||||
/// - Parameters:
|
||||
/// - name: The label name to save
|
||||
/// - context: The managed object context to use
|
||||
func saveNewLabel(name: String, context: NSManagedObjectContext) {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
|
||||
// Perform save in a synchronous block to ensure it completes before extension closes
|
||||
context.performAndWait {
|
||||
// Check if label already exists
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
do {
|
||||
let existingTags = try context.fetch(fetchRequest)
|
||||
|
||||
// Only create if it doesn't exist
|
||||
if existingTags.isEmpty {
|
||||
let newTag = TagEntity(context: context)
|
||||
newTag.name = trimmedName
|
||||
newTag.count = 1 // New label is being used immediately
|
||||
|
||||
try context.save()
|
||||
logger.info("Successfully saved new label '\(trimmedName)' to Core Data")
|
||||
|
||||
// Force immediate persistence to disk for share extension
|
||||
// Based on: https://www.avanderlee.com/swift/core-data-app-extension-data-sharing/
|
||||
// 1. Process pending changes
|
||||
context.processPendingChanges()
|
||||
|
||||
// 2. Ensure persistent store coordinator writes to disk
|
||||
// This is critical for extensions as they may be terminated quickly
|
||||
if context.persistentStoreCoordinator != nil {
|
||||
// Refresh all objects to ensure changes are pushed to store
|
||||
context.refreshAllObjects()
|
||||
|
||||
// Reset staleness interval temporarily to force immediate persistence
|
||||
let originalStalenessInterval = context.stalenessInterval
|
||||
context.stalenessInterval = 0
|
||||
context.refreshAllObjects()
|
||||
context.stalenessInterval = originalStalenessInterval
|
||||
|
||||
logger.debug("Forced context refresh to ensure persistence")
|
||||
}
|
||||
} else {
|
||||
logger.debug("Label '\(trimmedName)' already exists, skipping creation")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to save new label '\(trimmedName)': \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -452,7 +452,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -485,7 +485,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
@ -640,7 +640,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@ -684,7 +684,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
||||
@ -43,6 +43,11 @@ class CoreDataManager {
|
||||
self?.logger.info("Core Data persistent store loaded successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// Configure viewContext for better extension support
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
|
||||
return container
|
||||
}()
|
||||
|
||||
|
||||
@ -89,4 +89,29 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveNewLabel(name: String) async throws {
|
||||
let backgroundContext = coreDataManager.newBackgroundContext()
|
||||
|
||||
try await backgroundContext.perform {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
|
||||
// Check if label already exists
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "name == %@", trimmedName)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let existingTags = try backgroundContext.fetch(fetchRequest)
|
||||
|
||||
// Only create if it doesn't exist
|
||||
if existingTags.isEmpty {
|
||||
let newTag = TagEntity(context: backgroundContext)
|
||||
newTag.name = trimmedName
|
||||
newTag.count = 1 // New label is being used immediately
|
||||
|
||||
try backgroundContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,4 +3,5 @@ import Foundation
|
||||
protocol PLabelsRepository {
|
||||
func getLabels() async throws -> [BookmarkLabel]
|
||||
func saveLabels(_ dtos: [BookmarkLabelDto]) async throws
|
||||
func saveNewLabel(name: String) async throws
|
||||
}
|
||||
|
||||
17
readeck/Domain/UseCase/CreateLabelUseCase.swift
Normal file
17
readeck/Domain/UseCase/CreateLabelUseCase.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
protocol PCreateLabelUseCase {
|
||||
func execute(name: String) async throws
|
||||
}
|
||||
|
||||
class CreateLabelUseCase: PCreateLabelUseCase {
|
||||
private let labelsRepository: PLabelsRepository
|
||||
|
||||
init(labelsRepository: PLabelsRepository) {
|
||||
self.labelsRepository = labelsRepository
|
||||
}
|
||||
|
||||
func execute(name: String) async throws {
|
||||
try await labelsRepository.saveNewLabel(name: name)
|
||||
}
|
||||
}
|
||||
@ -190,6 +190,7 @@ struct AddBookmarkView: View {
|
||||
searchFieldFocus: $focusedField,
|
||||
fetchLimit: nil,
|
||||
sortOrder: appSettings.tagSortOrder,
|
||||
context: viewContext,
|
||||
onAddCustomTag: {
|
||||
viewModel.addCustomTag()
|
||||
},
|
||||
|
||||
@ -8,6 +8,7 @@ class AddBookmarkViewModel {
|
||||
|
||||
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
|
||||
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
|
||||
private let createLabelUseCase = DefaultUseCaseFactory.shared.makeCreateLabelUseCase()
|
||||
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
|
||||
|
||||
// MARK: - Form Data
|
||||
@ -87,17 +88,22 @@ class AddBookmarkViewModel {
|
||||
func addCustomTag() {
|
||||
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
|
||||
let lowercased = trimmed.lowercased()
|
||||
let allExisting = Set(allLabels.map { $0.name.lowercased() })
|
||||
let allSelected = Set(selectedLabels.map { $0.lowercased() })
|
||||
|
||||
|
||||
if allExisting.contains(lowercased) || allSelected.contains(lowercased) {
|
||||
// Tag already exists, don't add
|
||||
return
|
||||
} else {
|
||||
selectedLabels.insert(trimmed)
|
||||
searchText = ""
|
||||
|
||||
// Save new label to Core Data so it's available next time
|
||||
Task {
|
||||
try? await createLabelUseCase.execute(name: trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,12 +13,16 @@ import SwiftUI
|
||||
class AppViewModel {
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private let factory: UseCaseFactory
|
||||
private let syncTagsUseCase: PSyncTagsUseCase
|
||||
|
||||
var hasFinishedSetup: Bool = true
|
||||
var isServerReachable: Bool = false
|
||||
|
||||
private var lastAppStartTagSyncTime: Date?
|
||||
|
||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||
self.factory = factory
|
||||
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
|
||||
setupNotificationObservers()
|
||||
|
||||
loadSetupStatus()
|
||||
@ -65,12 +69,29 @@ class AppViewModel {
|
||||
|
||||
func onAppResume() async {
|
||||
await checkServerReachability()
|
||||
await syncTagsOnAppStart()
|
||||
}
|
||||
|
||||
private func checkServerReachability() async {
|
||||
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
|
||||
}
|
||||
|
||||
private func syncTagsOnAppStart() async {
|
||||
let now = Date()
|
||||
|
||||
// Check if last sync was less than 2 minutes ago
|
||||
if let lastSync = lastAppStartTagSyncTime,
|
||||
now.timeIntervalSince(lastSync) < 120 {
|
||||
print("AppViewModel: Skipping tag sync - last sync was less than 2 minutes ago")
|
||||
return
|
||||
}
|
||||
|
||||
// Sync tags from server to Core Data
|
||||
print("AppViewModel: Syncing tags on app start")
|
||||
try? await syncTagsUseCase.execute()
|
||||
lastAppStartTagSyncTime = now
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@ -71,6 +71,7 @@ struct BookmarkLabelsView: View {
|
||||
searchText: $viewModel.searchText,
|
||||
fetchLimit: nil,
|
||||
sortOrder: appSettings.tagSortOrder,
|
||||
context: viewContext,
|
||||
onAddCustomTag: {
|
||||
Task {
|
||||
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
|
||||
|
||||
@ -9,7 +9,8 @@ struct CoreDataTagManagementView: View {
|
||||
let searchText: Binding<String>
|
||||
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
|
||||
let sortOrder: TagSortOrder
|
||||
let availableTagsTitle: String?
|
||||
let availableLabelsTitle: String?
|
||||
let context: NSManagedObjectContext
|
||||
|
||||
// MARK: - Callbacks
|
||||
|
||||
@ -22,6 +23,11 @@ struct CoreDataTagManagementView: View {
|
||||
@FetchRequest
|
||||
private var tagEntities: FetchedResults<TagEntity>
|
||||
|
||||
// MARK: - Search State
|
||||
|
||||
@State private var searchResults: [TagEntity] = []
|
||||
@State private var isSearchActive: Bool = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
@ -30,7 +36,8 @@ struct CoreDataTagManagementView: View {
|
||||
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
|
||||
fetchLimit: Int? = nil,
|
||||
sortOrder: TagSortOrder = .byCount,
|
||||
availableTagsTitle: String? = nil,
|
||||
availableLabelsTitle: String? = nil,
|
||||
context: NSManagedObjectContext,
|
||||
onAddCustomTag: @escaping () -> Void,
|
||||
onToggleLabel: @escaping (String) -> Void,
|
||||
onRemoveLabel: @escaping (String) -> Void
|
||||
@ -39,7 +46,8 @@ struct CoreDataTagManagementView: View {
|
||||
self.searchText = searchText
|
||||
self.searchFieldFocus = searchFieldFocus
|
||||
self.sortOrder = sortOrder
|
||||
self.availableTagsTitle = availableTagsTitle
|
||||
self.availableLabelsTitle = availableLabelsTitle
|
||||
self.context = context
|
||||
self.onAddCustomTag = onAddCustomTag
|
||||
self.onToggleLabel = onToggleLabel
|
||||
self.onRemoveLabel = onRemoveLabel
|
||||
@ -79,13 +87,16 @@ struct CoreDataTagManagementView: View {
|
||||
availableLabels
|
||||
selectedLabels
|
||||
}
|
||||
.onChange(of: searchText.wrappedValue) { oldValue, newValue in
|
||||
performSearch(query: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Components
|
||||
|
||||
@ViewBuilder
|
||||
private var searchField: some View {
|
||||
TextField("Search or add new tag...", text: searchText)
|
||||
TextField("Search or add new label...", text: searchText)
|
||||
.textFieldStyle(CustomTextFieldStyle())
|
||||
.keyboardType(.default)
|
||||
.autocorrectionDisabled(true)
|
||||
@ -102,7 +113,7 @@ struct CoreDataTagManagementView: View {
|
||||
!allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) &&
|
||||
!selectedLabelsSet.contains(searchText.wrappedValue) {
|
||||
HStack {
|
||||
Text("Add new tag:")
|
||||
Text("Add new label:")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text(searchText.wrappedValue)
|
||||
@ -132,7 +143,7 @@ struct CoreDataTagManagementView: View {
|
||||
if !tagEntities.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(searchText.wrappedValue.isEmpty ? (availableTagsTitle ?? "Available tags") : "Search results")
|
||||
Text(searchText.wrappedValue.isEmpty ? (availableLabelsTitle ?? "Available labels") : "Search results")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
if !searchText.wrappedValue.isEmpty {
|
||||
@ -144,16 +155,31 @@ struct CoreDataTagManagementView: View {
|
||||
}
|
||||
|
||||
if availableUnselectedTagsCount == 0 {
|
||||
VStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.green)
|
||||
Text("All tags selected")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
// Show "All labels selected" only if there are actually filtered results
|
||||
// Otherwise show "No labels found" for empty search results
|
||||
if filteredTagsCount > 0 {
|
||||
VStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.green)
|
||||
Text("All labels selected")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
} else if !searchText.wrappedValue.isEmpty {
|
||||
VStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No labels found")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
} else {
|
||||
labelsScrollView
|
||||
}
|
||||
@ -174,17 +200,26 @@ struct CoreDataTagManagementView: View {
|
||||
alignment: .top,
|
||||
spacing: 8
|
||||
) {
|
||||
ForEach(tagEntities, id: \.objectID) { entity in
|
||||
if let name = entity.name, shouldShowTag(name) {
|
||||
UnifiedLabelChip(
|
||||
label: name,
|
||||
isSelected: false,
|
||||
isRemovable: false,
|
||||
onTap: {
|
||||
onToggleLabel(name)
|
||||
}
|
||||
)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
// Use searchResults when search is active, otherwise use tagEntities
|
||||
let tagsToDisplay = isSearchActive ? searchResults : Array(tagEntities)
|
||||
|
||||
ForEach(tagsToDisplay, id: \.objectID) { entity in
|
||||
if let name = entity.name {
|
||||
// When searching, show all results (already filtered by predicate)
|
||||
// When not searching, filter with shouldShowTag()
|
||||
let shouldShow = isSearchActive ? !selectedLabelsSet.contains(name) : shouldShowTag(name)
|
||||
|
||||
if shouldShow {
|
||||
UnifiedLabelChip(
|
||||
label: name,
|
||||
isSelected: false,
|
||||
isRemovable: false,
|
||||
onTap: {
|
||||
onToggleLabel(name)
|
||||
}
|
||||
)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -200,7 +235,9 @@ struct CoreDataTagManagementView: View {
|
||||
}
|
||||
|
||||
private var filteredTagsCount: Int {
|
||||
if searchText.wrappedValue.isEmpty {
|
||||
if isSearchActive {
|
||||
return searchResults.count
|
||||
} else if searchText.wrappedValue.isEmpty {
|
||||
return tagEntities.count
|
||||
} else {
|
||||
return tagEntities.filter { entity in
|
||||
@ -211,12 +248,19 @@ struct CoreDataTagManagementView: View {
|
||||
}
|
||||
|
||||
private var availableUnselectedTagsCount: Int {
|
||||
tagEntities.filter { entity in
|
||||
guard let name = entity.name else { return false }
|
||||
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
|
||||
let isNotSelected = !selectedLabelsSet.contains(name)
|
||||
return matchesSearch && isNotSelected
|
||||
}.count
|
||||
if isSearchActive {
|
||||
return searchResults.filter { entity in
|
||||
guard let name = entity.name else { return false }
|
||||
return !selectedLabelsSet.contains(name)
|
||||
}.count
|
||||
} else {
|
||||
return tagEntities.filter { entity in
|
||||
guard let name = entity.name else { return false }
|
||||
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
|
||||
let isNotSelected = !selectedLabelsSet.contains(name)
|
||||
return matchesSearch && isNotSelected
|
||||
}.count
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldShowTag(_ name: String) -> Bool {
|
||||
@ -225,11 +269,42 @@ struct CoreDataTagManagementView: View {
|
||||
return matchesSearch && isNotSelected
|
||||
}
|
||||
|
||||
private func performSearch(query: String) {
|
||||
guard !query.isEmpty else {
|
||||
isSearchActive = false
|
||||
searchResults = []
|
||||
return
|
||||
}
|
||||
|
||||
// Search directly in Core Data without fetchLimit
|
||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", query)
|
||||
|
||||
// Use same sort order as main fetch
|
||||
let sortDescriptors: [NSSortDescriptor]
|
||||
switch sortOrder {
|
||||
case .byCount:
|
||||
sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \TagEntity.count, ascending: false),
|
||||
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
|
||||
]
|
||||
case .alphabetically:
|
||||
sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \TagEntity.name, ascending: true)
|
||||
]
|
||||
}
|
||||
fetchRequest.sortDescriptors = sortDescriptors
|
||||
|
||||
// NO fetchLimit - search ALL tags in database
|
||||
searchResults = (try? context.fetch(fetchRequest)) ?? []
|
||||
isSearchActive = true
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedLabels: some View {
|
||||
if !selectedLabelsSet.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Selected tags")
|
||||
Text("Selected labels")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ protocol UseCaseFactory {
|
||||
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
|
||||
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||
func makeCreateLabelUseCase() -> PCreateLabelUseCase
|
||||
func makeSyncTagsUseCase() -> PSyncTagsUseCase
|
||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||
@ -104,6 +105,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
||||
}
|
||||
|
||||
func makeCreateLabelUseCase() -> PCreateLabelUseCase {
|
||||
let api = API(tokenProvider: KeychainTokenProvider())
|
||||
let labelsRepository = LabelsRepository(api: api)
|
||||
return CreateLabelUseCase(labelsRepository: labelsRepository)
|
||||
}
|
||||
|
||||
func makeSyncTagsUseCase() -> PSyncTagsUseCase {
|
||||
let api = API(tokenProvider: KeychainTokenProvider())
|
||||
let labelsRepository = LabelsRepository(api: api)
|
||||
|
||||
@ -77,6 +77,10 @@ class MockUseCaseFactory: UseCaseFactory {
|
||||
MockGetLabelsUseCase()
|
||||
}
|
||||
|
||||
func makeCreateLabelUseCase() -> any PCreateLabelUseCase {
|
||||
MockCreateLabelUseCase()
|
||||
}
|
||||
|
||||
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
|
||||
MockSyncTagsUseCase()
|
||||
}
|
||||
@ -129,6 +133,12 @@ class MockGetLabelsUseCase: PGetLabelsUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
class MockCreateLabelUseCase: PCreateLabelUseCase {
|
||||
func execute(name: String) async throws {
|
||||
// Mock implementation - does nothing
|
||||
}
|
||||
}
|
||||
|
||||
class MockSyncTagsUseCase: PSyncTagsUseCase {
|
||||
func execute() async throws {
|
||||
// Mock implementation - does nothing
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user