Refactor tag management to use Core Data with configurable sorting

This commit introduces a comprehensive refactoring of the tag management
system, replacing the previous API-based approach with a Core Data-first
strategy for improved performance and offline support.

Major Changes:

Tag Management Architecture:
- Add CoreDataTagManagementView using @FetchRequest for reactive updates
- Implement cache-first sync strategy in LabelsRepository
- Create SyncTagsUseCase following Clean Architecture principles
- Add TagSortOrder enum for configurable tag sorting (by count/alphabetically)
- Mark LegacyTagManagementView as deprecated

Share Extension Improvements:
- Replace API-based tag loading with Core Data queries
- Display top 150 tags sorted by usage count
- Remove unnecessary label fetching logic
- Add "Most used tags" localized title
- Improve offline bookmark tag management

Main App Enhancements:
- Add tag sync triggers in AddBookmarkView and BookmarkLabelsView
- Implement user-configurable tag sorting in settings
- Add sort order indicator labels with localization
- Automatic UI updates via SwiftUI @FetchRequest reactivity

Settings & Configuration:
- Add TagSortOrder setting with persistence
- Refactor Settings model structure
- Add FontFamily and FontSize domain models
- Improve settings repository with tag sort order support

Use Case Layer:
- Add SyncTagsUseCase for background tag synchronization
- Update UseCaseFactory with tag sync support
- Add mock implementations for testing

Localization:
- Add German and English translations for:
  - "Most used tags"
  - "Sorted by usage count"
  - "Sorted alphabetically"

Technical Improvements:
- Batch tag updates with conflict detection
- Background sync with silent failure handling
- Reduced server load through local caching
- Better separation of concerns following Clean Architecture
This commit is contained in:
Ilyas Hallak 2025-11-08 13:46:40 +01:00
parent 460b05ef34
commit f3719fa9d4
31 changed files with 747 additions and 264 deletions

View File

@ -84,6 +84,7 @@ class OfflineBookmarkManager: @unchecked Sendable {
if !existingNames.contains(tag) {
let entity = TagEntity(context: backgroundContext)
entity.name = tag
entity.count = 0
insertCount += 1
}
}
@ -99,4 +100,51 @@ class OfflineBookmarkManager: @unchecked Sendable {
}
}
func saveTagsWithCount(_ tags: [BookmarkLabelDto]) async {
let backgroundContext = CoreDataManager.shared.newBackgroundContext()
do {
try await backgroundContext.perform {
// Batch fetch existing tags
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
var existingByName: [String: TagEntity] = [:]
for entity in existingEntities {
if let name = entity.name {
existingByName[name] = entity
}
}
// Insert or update tags
var insertCount = 0
var updateCount = 0
for tag in tags {
if let existing = existingByName[tag.name] {
// Update count if changed
if existing.count != tag.count {
existing.count = Int32(tag.count)
updateCount += 1
}
} else {
// Insert new tag
let entity = TagEntity(context: backgroundContext)
entity.name = tag.name
entity.count = Int32(tag.count)
insertCount += 1
}
}
// Only save if there are changes
if insertCount > 0 || updateCount > 0 {
try backgroundContext.save()
print("Saved \(insertCount) new tags and updated \(updateCount) tags to Core Data")
}
}
} catch {
print("Failed to save tags with count: \(error)")
}
}
}

View File

@ -1,10 +1,13 @@
import SwiftUI
import CoreData
struct ShareBookmarkView: View {
@ObservedObject var viewModel: ShareBookmarkViewModel
@State private var keyboardHeight: CGFloat = 0
@FocusState private var focusedField: AddBookmarkFieldFocus?
@Environment(\.managedObjectContext) private var viewContext
private func dismissKeyboard() {
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
}
@ -39,7 +42,6 @@ struct ShareBookmarkView: View {
saveButtonSection
}
.background(Color(.systemGroupedBackground))
.onAppear { viewModel.onAppear() }
.ignoresSafeArea(.keyboard, edges: .bottom)
.contentShape(Rectangle())
.onTapGesture {
@ -134,14 +136,13 @@ struct ShareBookmarkView: View {
@ViewBuilder
private var tagManagementSection: some View {
if !viewModel.labels.isEmpty || !viewModel.isServerReachable {
TagManagementView(
allLabels: convertToBookmarkLabels(viewModel.labels),
CoreDataTagManagementView(
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: false,
filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels),
searchFieldFocus: $focusedField,
fetchLimit: 150,
sortOrder: viewModel.tagSortOrder,
availableTagsTitle: "Most used tags",
onAddCustomTag: {
addCustomTag()
},
@ -160,7 +161,6 @@ struct ShareBookmarkView: View {
.padding(.top, 20)
.padding(.horizontal, 16)
}
}
@ViewBuilder
private var statusSection: some View {
@ -199,17 +199,13 @@ struct ShareBookmarkView: View {
// MARK: - Helper Functions
private func convertToBookmarkLabels(_ dtos: [BookmarkLabelDto]) -> [BookmarkLabel] {
return dtos.map { .init(name: $0.name, count: $0.count, href: $0.href) }
}
private func convertToBookmarkLabelPages(_ dtoPages: [[BookmarkLabelDto]]) -> [[BookmarkLabel]] {
return dtoPages.map { convertToBookmarkLabels($0) }
}
private func addCustomTag() {
let splitLabels = LabelUtils.splitLabelsFromInput(viewModel.searchText)
let availableLabels = viewModel.labels.map { $0.name }
// 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)

View File

@ -6,54 +6,23 @@ 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 tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount
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 {
@ -120,51 +89,6 @@ class ShareBookmarkViewModel: ObservableObject {
}
}
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 {
@ -205,19 +129,23 @@ class ShareBookmarkViewModel: ObservableObject {
)
logger.info("Local save result: \(success)")
DispatchQueue.main.async {
await MainActor.run {
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, "")
}
}
if success {
try? await Task.sleep(nanoseconds: 2_000_000_000)
await MainActor.run {
self.completeExtensionRequest()
}
}
}
}
}

View File

@ -12,13 +12,14 @@ import SwiftUI
class ShareViewController: UIViewController {
private var hostingController: UIHostingController<ShareBookmarkView>?
private var hostingController: UIHostingController<AnyView>?
override func viewDidLoad() {
super.viewDidLoad()
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext)
let swiftUIView = ShareBookmarkView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: swiftUIView)
.environment(\.managedObjectContext, CoreDataManager.shared.context)
let hostingController = UIHostingController(rootView: AnyView(swiftUIView))
addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view)

View File

@ -87,12 +87,22 @@
Data/Utils/LabelUtils.swift,
Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift,
Domain/Model/CardLayoutStyle.swift,
Domain/Model/FontFamily.swift,
Domain/Model/FontSize.swift,
Domain/Model/Settings.swift,
Domain/Model/TagSortOrder.swift,
Domain/Model/Theme.swift,
Domain/Model/UrlOpener.swift,
readeck.xcdatamodeld,
Splash.storyboard,
UI/Components/Constants.swift,
UI/Components/CoreDataTagManagementView.swift,
UI/Components/CustomTextFieldStyle.swift,
UI/Components/TagManagementView.swift,
UI/Components/LegacyTagManagementView.swift,
UI/Components/UnifiedLabelChip.swift,
UI/Extension/FontSizeExtension.swift,
UI/Models/AppSettings.swift,
UI/Utils/NotificationNames.swift,
Utils/Logger.swift,
Utils/LogStore.swift,

View File

@ -14,6 +14,7 @@ extension BookmarkLabelDto {
func toEntity(context: NSManagedObjectContext) -> TagEntity {
let entity = TagEntity(context: context)
entity.name = name
entity.count = Int32(count)
return entity
}
}

View File

@ -33,14 +33,17 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
return try await backgroundContext.perform {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "count", ascending: false),
NSSortDescriptor(key: "name", ascending: true)
]
let entities = try backgroundContext.fetch(fetchRequest)
return entities.compactMap { entity -> BookmarkLabel? in
guard let name = entity.name, !name.isEmpty else { return nil }
return BookmarkLabel(
name: name,
count: 0,
count: Int(entity.count),
href: name
)
}
@ -51,24 +54,37 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable {
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform {
// Batch fetch all existing label names (much faster than individual queries)
// Batch fetch all existing labels
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
fetchRequest.propertiesToFetch = ["name"]
fetchRequest.propertiesToFetch = ["name", "count"]
let existingEntities = try backgroundContext.fetch(fetchRequest)
let existingNames = Set(existingEntities.compactMap { $0.name })
var existingByName: [String: TagEntity] = [:]
for entity in existingEntities {
if let name = entity.name {
existingByName[name] = entity
}
}
// Only insert new labels
// Insert or update labels
var insertCount = 0
var updateCount = 0
for dto in dtos {
if !existingNames.contains(dto.name) {
if let existing = existingByName[dto.name] {
// Update count if changed
if existing.count != dto.count {
existing.count = Int32(dto.count)
updateCount += 1
}
} else {
// Insert new label
dto.toEntity(context: backgroundContext)
insertCount += 1
}
}
// Only save if there are new labels
if insertCount > 0 {
// Only save if there are changes
if insertCount > 0 || updateCount > 0 {
try backgroundContext.save()
}
}

View File

@ -1,30 +1,6 @@
import Foundation
import CoreData
struct Settings {
var endpoint: String? = nil
var username: String? = nil
var password: String? = nil
var token: String? = nil
var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var urlOpener: UrlOpener? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
}
mutating func setToken(_ newToken: String) {
token = newToken
}
}
protocol PSettingsRepository {
func saveSettings(_ settings: Settings) async throws
func loadSettings() async throws -> Settings?
@ -36,6 +12,8 @@ protocol PSettingsRepository {
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
func loadCardLayoutStyle() async throws -> CardLayoutStyle
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
func loadTagSortOrder() async throws -> TagSortOrder
var hasFinishedSetup: Bool { get }
}
@ -101,6 +79,10 @@ class SettingsRepository: PSettingsRepository {
existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue
}
if let tagSortOrder = settings.tagSortOrder {
existingSettings.tagSortOrder = tagSortOrder.rawValue
}
try context.save()
continuation.resume()
} catch {
@ -139,6 +121,7 @@ class SettingsRepository: PSettingsRepository {
enableTTS: settingEntity?.enableTTS,
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue),
cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue),
tagSortOrder: TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue),
urlOpener: UrlOpener(rawValue: settingEntity?.urlOpener ?? UrlOpener.inAppBrowser.rawValue)
)
continuation.resume(returning: settings)
@ -262,4 +245,45 @@ class SettingsRepository: PSettingsRepository {
}
}
}
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
existingSettings.tagSortOrder = tagSortOrder.rawValue
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func loadTagSortOrder() async throws -> TagSortOrder {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
let settingEntity = settingEntities.first
let tagSortOrder = TagSortOrder(rawValue: settingEntity?.tagSortOrder ?? TagSortOrder.byCount.rawValue) ?? .byCount
continuation.resume(returning: tagSortOrder)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}

View File

@ -0,0 +1,23 @@
//
// FontFamily.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}

View File

@ -0,0 +1,33 @@
//
// FontSize.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
import Foundation
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
}

View File

@ -0,0 +1,32 @@
//
// Settings.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
struct Settings {
var endpoint: String? = nil
var username: String? = nil
var password: String? = nil
var token: String? = nil
var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false
var enableTTS: Bool? = nil
var theme: Theme? = nil
var cardLayoutStyle: CardLayoutStyle? = nil
var tagSortOrder: TagSortOrder? = nil
var urlOpener: UrlOpener? = nil
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
}
mutating func setToken(_ newToken: String) {
token = newToken
}
}

View File

@ -0,0 +1,20 @@
//
// TagSortOrder.swift
// readeck
//
// Created by Ilyas Hallak
//
import Foundation
enum TagSortOrder: String, CaseIterable {
case byCount = "count"
case alphabetically = "alphabetically"
var displayName: String {
switch self {
case .byCount: return "By usage count"
case .alphabetically: return "Alphabetically"
}
}
}

View File

@ -1,3 +1,10 @@
//
// UrlOpener.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
enum UrlOpener: String, CaseIterable {
case inAppBrowser = "inAppBrowser"
case defaultBrowser = "defaultBrowser"

View File

@ -0,0 +1,21 @@
import Foundation
protocol PSyncTagsUseCase {
func execute() async throws
}
/// Triggers background synchronization of tags from server to Core Data
/// Uses cache-first strategy - returns immediately after triggering sync
class SyncTagsUseCase: PSyncTagsUseCase {
private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) {
self.labelsRepository = labelsRepository
}
func execute() async throws {
// Trigger the sync - getLabels() uses cache-first + background sync strategy
// We don't need the return value, just triggering the sync is enough
_ = try await labelsRepository.getLabels()
}
}

View File

@ -59,6 +59,9 @@
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Dieses Lesezeichen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Wirklich abmelden? Dies löscht alle Anmeldedaten und führt zurück zur Einrichtung.";
"Available tags" = "Verfügbare Labels";
"Most used tags" = "Meist verwendete Labels";
"Sorted by usage count" = "Sortiert nach Verwendungshäufigkeit";
"Sorted alphabetically" = "Alphabetisch sortiert";
"Cancel" = "Abbrechen";
"Category-specific Levels" = "Kategorie-spezifische Level";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Änderungen werden sofort wirksam. Niedrigere Log-Level enthalten höhere (Debug enthält alle, Critical nur kritische Nachrichten).";

View File

@ -55,6 +55,9 @@
"Are you sure you want to delete this bookmark? This action cannot be undone." = "Are you sure you want to delete this bookmark? This action cannot be undone.";
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." = "Are you sure you want to log out? This will delete all your login credentials and return you to setup.";
"Available tags" = "Available tags";
"Most used tags" = "Most used tags";
"Sorted by usage count" = "Sorted by usage count";
"Sorted alphabetically" = "Sorted alphabetically";
"Cancel" = "Cancel";
"Category-specific Levels" = "Category-specific Levels";
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." = "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).";

View File

@ -4,6 +4,8 @@ import UIKit
struct AddBookmarkView: View {
@State private var viewModel = AddBookmarkViewModel()
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject private var appSettings: AppSettings
@FocusState private var focusedField: AddBookmarkFieldFocus?
@State private var keyboardHeight: CGFloat = 0
@ -58,9 +60,9 @@ struct AddBookmarkView: View {
}
.onAppear {
viewModel.checkClipboard()
Task {
await viewModel.syncTags()
}
.task {
await viewModel.loadAllLabels()
}
.onDisappear {
viewModel.clearForm()
@ -177,13 +179,17 @@ struct AddBookmarkView: View {
@ViewBuilder
private var labelsField: some View {
TagManagementView(
allLabels: viewModel.allLabels,
VStack(alignment: .leading, spacing: 8) {
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
.font(.caption)
.foregroundColor(.secondary)
CoreDataTagManagementView(
selectedLabels: viewModel.selectedLabels,
searchText: $viewModel.searchText,
isLabelsLoading: viewModel.isLabelsLoading,
filteredLabels: viewModel.filteredLabels,
searchFieldFocus: $focusedField,
fetchLimit: nil,
sortOrder: appSettings.tagSortOrder,
onAddCustomTag: {
viewModel.addCustomTag()
},
@ -195,6 +201,7 @@ struct AddBookmarkView: View {
}
)
}
}
@ViewBuilder
private var bottomActionArea: some View {

View File

@ -8,6 +8,7 @@ class AddBookmarkViewModel {
private let createBookmarkUseCase = DefaultUseCaseFactory.shared.makeCreateBookmarkUseCase()
private let getLabelsUseCase = DefaultUseCaseFactory.shared.makeGetLabelsUseCase()
private let syncTagsUseCase = DefaultUseCaseFactory.shared.makeSyncTagsUseCase()
// MARK: - Form Data
var url: String = ""
@ -61,6 +62,13 @@ class AddBookmarkViewModel {
// MARK: - Labels Management
/// Triggers background sync of tags from server to Core Data
/// CoreDataTagManagementView will automatically update via @FetchRequest
@MainActor
func syncTags() async {
try? await syncTagsUseCase.execute()
}
@MainActor
func loadAllLabels() async {
isLabelsLoading = true

View File

@ -4,6 +4,8 @@ struct BookmarkLabelsView: View {
let bookmarkId: String
@State private var viewModel: BookmarkLabelsViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject private var appSettings: AppSettings
init(bookmarkId: String, initialLabels: [String], viewModel: BookmarkLabelsViewModel? = nil) {
self.bookmarkId = bookmarkId
@ -40,13 +42,15 @@ struct BookmarkLabelsView: View {
} message: {
Text(viewModel.errorMessage ?? "Unknown error")
}
.task {
await viewModel.loadAllLabels()
}
.ignoresSafeArea(.keyboard)
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
.onAppear {
Task {
await viewModel.syncTags()
}
}
}
}
@ -56,12 +60,17 @@ struct BookmarkLabelsView: View {
@ViewBuilder
private var availableLabelsSection: some View {
TagManagementView(
allLabels: viewModel.allLabels,
VStack(alignment: .leading, spacing: 8) {
Text(appSettings.tagSortOrder == .byCount ? "Sorted by usage count".localized : "Sorted alphabetically".localized)
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
CoreDataTagManagementView(
selectedLabels: Set(viewModel.currentLabels),
searchText: $viewModel.searchText,
isLabelsLoading: viewModel.isInitialLoading,
filteredLabels: viewModel.filteredLabels,
fetchLimit: nil,
sortOrder: appSettings.tagSortOrder,
onAddCustomTag: {
Task {
await viewModel.addLabel(to: bookmarkId, label: viewModel.searchText)
@ -81,6 +90,7 @@ struct BookmarkLabelsView: View {
.padding(.horizontal)
}
}
}
#Preview {
BookmarkLabelsView(bookmarkId: "test-id", initialLabels: ["wichtig", "arbeit", "persönlich"], viewModel: .init(MockUseCaseFactory(), initialLabels: ["test"]))

View File

@ -5,6 +5,7 @@ class BookmarkLabelsViewModel {
private let addLabelsUseCase: PAddLabelsToBookmarkUseCase
private let removeLabelsUseCase: PRemoveLabelsFromBookmarkUseCase
private let getLabelsUseCase: PGetLabelsUseCase
private let syncTagsUseCase: PSyncTagsUseCase
var isLoading = false
var isInitialLoading = false
@ -34,7 +35,14 @@ class BookmarkLabelsViewModel {
self.addLabelsUseCase = factory.makeAddLabelsToBookmarkUseCase()
self.removeLabelsUseCase = factory.makeRemoveLabelsFromBookmarkUseCase()
self.getLabelsUseCase = factory.makeGetLabelsUseCase()
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
}
/// Triggers background sync of tags from server to Core Data
/// CoreDataTagManagementView will automatically update via @FetchRequest
@MainActor
func syncTags() async {
try? await syncTagsUseCase.execute()
}
@MainActor

View File

@ -0,0 +1,255 @@
import SwiftUI
import CoreData
struct CoreDataTagManagementView: View {
// MARK: - Properties
let selectedLabelsSet: Set<String>
let searchText: Binding<String>
let searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding?
let sortOrder: TagSortOrder
let availableTagsTitle: String?
// MARK: - Callbacks
let onAddCustomTag: () -> Void
let onToggleLabel: (String) -> Void
let onRemoveLabel: (String) -> Void
// MARK: - FetchRequest
@FetchRequest
private var tagEntities: FetchedResults<TagEntity>
// MARK: - Initialization
init(
selectedLabels: Set<String>,
searchText: Binding<String>,
searchFieldFocus: FocusState<AddBookmarkFieldFocus?>.Binding? = nil,
fetchLimit: Int? = nil,
sortOrder: TagSortOrder = .byCount,
availableTagsTitle: String? = nil,
onAddCustomTag: @escaping () -> Void,
onToggleLabel: @escaping (String) -> Void,
onRemoveLabel: @escaping (String) -> Void
) {
self.selectedLabelsSet = selectedLabels
self.searchText = searchText
self.searchFieldFocus = searchFieldFocus
self.sortOrder = sortOrder
self.availableTagsTitle = availableTagsTitle
self.onAddCustomTag = onAddCustomTag
self.onToggleLabel = onToggleLabel
self.onRemoveLabel = onRemoveLabel
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
// Apply sort order from parameter
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
if let limit = fetchLimit {
fetchRequest.fetchLimit = limit
}
fetchRequest.fetchBatchSize = 20
_tagEntities = FetchRequest(
fetchRequest: fetchRequest,
animation: .default
)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
searchField
customTagSuggestion
availableLabels
selectedLabels
}
}
// MARK: - View Components
@ViewBuilder
private var searchField: some View {
TextField("Search or add new tag...", text: searchText)
.textFieldStyle(CustomTextFieldStyle())
.keyboardType(.default)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.onSubmit {
onAddCustomTag()
}
.modifier(FocusModifier(focusBinding: searchFieldFocus, field: .labels))
}
@ViewBuilder
private var customTagSuggestion: some View {
if !searchText.wrappedValue.isEmpty &&
!allTagNames.contains(where: { $0.lowercased() == searchText.wrappedValue.lowercased() }) &&
!selectedLabelsSet.contains(searchText.wrappedValue) {
HStack {
Text("Add new tag:")
.font(.subheadline)
.foregroundColor(.secondary)
Text(searchText.wrappedValue)
.font(.subheadline)
.fontWeight(.medium)
Spacer()
Button(action: onAddCustomTag) {
HStack(spacing: 6) {
Image(systemName: "plus.circle.fill")
.font(.subheadline)
Text("Add")
.font(.subheadline)
.fontWeight(.medium)
}
}
.foregroundColor(.accentColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(Color.accentColor.opacity(0.1))
.cornerRadius(10)
}
}
@ViewBuilder
private var availableLabels: some View {
if !tagEntities.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(searchText.wrappedValue.isEmpty ? (availableTagsTitle ?? "Available tags") : "Search results")
.font(.subheadline)
.fontWeight(.medium)
if !searchText.wrappedValue.isEmpty {
Text("(\(filteredTagsCount) found)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
if availableUnselectedTagsCount == 0 {
VStack {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 24))
.foregroundColor(.green)
Text("All tags selected")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
labelsScrollView
}
}
.padding(.top, 8)
}
}
@ViewBuilder
private var labelsScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(
rows: [
GridItem(.fixed(32), spacing: 8),
GridItem(.fixed(32), spacing: 8),
GridItem(.fixed(32), spacing: 8)
],
alignment: .top,
spacing: 8
) {
ForEach(tagEntities) { entity in
if let name = entity.name, shouldShowTag(name) {
UnifiedLabelChip(
label: name,
isSelected: false,
isRemovable: false,
onTap: {
onToggleLabel(name)
}
)
.fixedSize(horizontal: true, vertical: false)
}
}
}
.frame(height: 120) // 3 rows * 32px + 2 * 8px spacing
.padding(.horizontal)
}
}
// MARK: - Computed Properties & Helper Functions
private var allTagNames: [String] {
tagEntities.compactMap { $0.name }
}
private var filteredTagsCount: Int {
if searchText.wrappedValue.isEmpty {
return tagEntities.count
} else {
return tagEntities.filter { entity in
guard let name = entity.name else { return false }
return name.localizedCaseInsensitiveContains(searchText.wrappedValue)
}.count
}
}
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
}
private func shouldShowTag(_ name: String) -> Bool {
let matchesSearch = searchText.wrappedValue.isEmpty || name.localizedCaseInsensitiveContains(searchText.wrappedValue)
let isNotSelected = !selectedLabelsSet.contains(name)
return matchesSearch && isNotSelected
}
@ViewBuilder
private var selectedLabels: some View {
if !selectedLabelsSet.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Selected tags")
.font(.subheadline)
.fontWeight(.medium)
FlowLayout(spacing: 8) {
ForEach(selectedLabelsSet.sorted(), id: \.self) { label in
UnifiedLabelChip(
label: label,
isSelected: true,
isRemovable: true,
onTap: {
// No action for selected labels
},
onRemove: {
onRemoveLabel(label)
}
)
}
}
}
.padding(.top, 8)
}
}
}

View File

@ -1,3 +1,7 @@
// TODO: deprecated - This file is no longer used and can be removed
// Replaced by CoreDataTagManagementView.swift which uses Core Data directly
// instead of fetching labels via API
import SwiftUI
struct FlowLayout: Layout {
@ -75,7 +79,7 @@ struct FocusModifier: ViewModifier {
}
}
struct TagManagementView: View {
struct LegacyTagManagementView: View {
// MARK: - Properties

View File

@ -0,0 +1,14 @@
//
// FontSizeExtension.swift
// readeck
//
// Created by Ilyas Hallak on 06.11.25.
//
import SwiftUI
extension FontSize {
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -16,6 +16,7 @@ protocol UseCaseFactory {
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> PGetLabelsUseCase
func makeSyncTagsUseCase() -> PSyncTagsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
@ -103,6 +104,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
return GetLabelsUseCase(labelsRepository: labelsRepository)
}
func makeSyncTagsUseCase() -> PSyncTagsUseCase {
let api = API(tokenProvider: KeychainTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return SyncTagsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase()
}

View File

@ -77,6 +77,10 @@ class MockUseCaseFactory: UseCaseFactory {
MockGetLabelsUseCase()
}
func makeSyncTagsUseCase() -> any PSyncTagsUseCase {
MockSyncTagsUseCase()
}
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
MockAddTextToSpeechQueueUseCase()
}
@ -125,6 +129,12 @@ class MockGetLabelsUseCase: PGetLabelsUseCase {
}
}
class MockSyncTagsUseCase: PSyncTagsUseCase {
func execute() async throws {
// Mock implementation - does nothing
}
}
class MockSearchBookmarksUseCase: PSearchBookmarksUseCase {
func execute(search: String) async throws -> BookmarksPage {
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)

View File

@ -31,6 +31,10 @@ class AppSettings: ObservableObject {
settings?.urlOpener ?? .inAppBrowser
}
var tagSortOrder: TagSortOrder {
settings?.tagSortOrder ?? .byCount
}
init(settings: Settings? = nil) {
self.settings = settings
}

View File

@ -3,9 +3,12 @@ import SwiftUI
struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system
@State private var selectedTagSortOrder: TagSortOrder = .byCount
@State private var fontViewModel: FontSettingsViewModel
@State private var generalViewModel: SettingsGeneralViewModel
@EnvironmentObject private var appSettings: AppSettings
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
private let settingsRepository: PSettingsRepository
@ -104,10 +107,20 @@ struct AppearanceSettingsView: View {
await generalViewModel.saveGeneralSettings()
}
}
// Tag Sort Order
Picker("Tag sort order", selection: $selectedTagSortOrder) {
ForEach(TagSortOrder.allCases, id: \.self) { sortOrder in
Text(sortOrder.displayName).tag(sortOrder)
}
}
.onChange(of: selectedTagSortOrder) {
saveTagSortOrderSettings()
}
} header: {
Text("Appearance")
} footer: {
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.")
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.\n\nTag sort order determines how tags are displayed when adding or editing bookmarks.")
}
}
.task {
@ -119,10 +132,11 @@ struct AppearanceSettingsView: View {
private func loadSettings() {
Task {
// Load both theme and card layout from repository
// Load theme, card layout, and tag sort order from repository
if let settings = try? await settingsRepository.loadSettings() {
await MainActor.run {
selectedTheme = settings.theme ?? .system
selectedTagSortOrder = settings.tagSortOrder ?? .byCount
}
}
selectedCardLayout = await loadCardLayoutUseCase.execute()
@ -152,6 +166,20 @@ struct AppearanceSettingsView: View {
}
}
}
private func saveTagSortOrderSettings() {
Task {
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
settings.tagSortOrder = selectedTagSortOrder
try? await settingsRepository.saveSettings(settings)
// Update AppSettings to trigger UI updates
await MainActor.run {
appSettings.settings?.tagSortOrder = selectedTagSortOrder
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
}
}
}
#Preview {

View File

@ -99,48 +99,6 @@ class FontSettingsViewModel {
}
}
// MARK: - Font Enums (moved from SettingsViewModel)
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -14,4 +14,5 @@ extension Notification.Name {
// MARK: - User Preferences
static let cardLayoutChanged = Notification.Name("cardLayoutChanged")
static let tagSortOrderChanged = Notification.Name("tagSortOrderChanged")
}

View File

@ -25,6 +25,7 @@ struct readeckApp: App {
}
}
.environmentObject(appSettings)
.environment(\.managedObjectContext, CoreDataManager.shared.context)
.preferredColorScheme(appSettings.theme.colorScheme)
.onAppear {
#if DEBUG

View File

@ -55,11 +55,13 @@
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fontFamily" optional="YES" attributeType="String"/>
<attribute name="fontSize" optional="YES" attributeType="String"/>
<attribute name="tagSortOrder" optional="YES" attributeType="String"/>
<attribute name="theme" optional="YES" attributeType="String"/>
<attribute name="token" optional="YES" attributeType="String"/>
<attribute name="urlOpener" optional="YES" attributeType="String"/>
</entity>
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
<attribute name="count" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
</entity>
</model>