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:
Ilyas Hallak 2025-11-10 21:29:38 +01:00
parent 4134b41be2
commit a3b3863fa3
15 changed files with 301 additions and 57 deletions

View File

@ -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)
}
}

View File

@ -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)")

View 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)")
}
}
}
}

View File

@ -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;

View File

@ -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
}()

View File

@ -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()
}
}
}
}

View File

@ -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
}

View 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)
}
}

View File

@ -190,6 +190,7 @@ struct AddBookmarkView: View {
searchFieldFocus: $focusedField,
fetchLimit: nil,
sortOrder: appSettings.tagSortOrder,
context: viewContext,
onAddCustomTag: {
viewModel.addCustomTag()
},

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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