From cf4a1ad4dbc8f4a3f61a0a1a32f63d31c3562b27 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 03:07:40 +0800
Subject: [PATCH] Record TagLauncher 8.3.2 release freeze
---
src/Apptag/DataLayer.swift | 752 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 files changed, 713 insertions(+), 39 deletions(-)
diff --git a/src/Apptag/DataLayer.swift b/src/Apptag/DataLayer.swift
index 1cc92f4..ac1650c 100644
--- a/src/Apptag/DataLayer.swift
+++ b/src/Apptag/DataLayer.swift
@@ -41,6 +41,44 @@
static func == (lhs: AppInfo, rhs: AppInfo) -> Bool { lhs.path == rhs.path }
}
+enum AppContainerID {
+ static let uncategorized = "__container.uncategorized"
+ static let appleBuiltIn = "__container.appleBuiltIn"
+
+ static func tag(_ name: String) -> String {
+ "tag:\(name)"
+ }
+
+ static func system(_ id: SmartCategoryID) -> String {
+ "system:\(id.rawValue)"
+ }
+
+ static func forTag(_ name: String, definition: TagDatabase.TagDef?) -> String {
+ if let categoryID = definition?.systemCategoryID {
+ return system(categoryID)
+ }
+ return tag(name)
+ }
+
+ static func forLegacyGroupName(_ name: String) -> String {
+ if isUncategorizedDisplayName(name) {
+ return uncategorized
+ }
+ if isAppleBuiltInDisplayName(name) {
+ return appleBuiltIn
+ }
+ return tag(name)
+ }
+
+ private static func isUncategorizedDisplayName(_ name: String) -> Bool {
+ name == "Other" || name == tr("group.uncategorized")
+ }
+
+ private static func isAppleBuiltInDisplayName(_ name: String) -> Bool {
+ name == "Mac自带" || name == tr("group.appleBuiltIn")
+ }
+}
+
enum AppDisplayNameResolver {
static func displayName(for app: AppInfo, languageCode: String) -> String {
let languageFallbacks = displayLanguageFallbacks(for: languageCode, includeEnglish: false)
@@ -177,12 +215,73 @@
}
struct TagGroup: Identifiable {
- var id: String { name }
+ var id: String { containerID }
+ let containerID: String
let name: String
let apps: [AppInfo]
+
+ init(containerID: String, name: String, apps: [AppInfo]) {
+ self.containerID = containerID
+ self.name = name
+ self.apps = apps
+ }
+
+ init(name: String, apps: [AppInfo]) {
+ self.init(
+ containerID: AppContainerID.forLegacyGroupName(name),
+ name: name,
+ apps: apps
+ )
+ }
}
// MARK: - Tag Color Mapping
+
+struct TagCustomColor: Codable, Equatable {
+ var red: Double
+ var green: Double
+ var blue: Double
+ var alpha: Double
+
+ init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) {
+ self.red = Self.clamp(red)
+ self.green = Self.clamp(green)
+ self.blue = Self.clamp(blue)
+ self.alpha = Self.clamp(alpha)
+ }
+
+ init(nsColor: NSColor) {
+ let rgbColor = nsColor.usingColorSpace(.sRGB) ?? nsColor
+ self.init(
+ red: Double(rgbColor.redComponent),
+ green: Double(rgbColor.greenComponent),
+ blue: Double(rgbColor.blueComponent),
+ alpha: Double(rgbColor.alphaComponent)
+ )
+ }
+
+ var nsColor: NSColor {
+ NSColor(
+ srgbRed: CGFloat(red),
+ green: CGFloat(green),
+ blue: CGFloat(blue),
+ alpha: CGFloat(alpha)
+ )
+ }
+
+ var signature: String {
+ [
+ String(format: "%.4f", red),
+ String(format: "%.4f", green),
+ String(format: "%.4f", blue),
+ String(format: "%.4f", alpha)
+ ].joined(separator: ":")
+ }
+
+ private static func clamp(_ value: Double) -> Double {
+ min(1.0, max(0.0, value))
+ }
+}
enum TagColor {
static func nsColor(for index: Int) -> NSColor {
@@ -198,6 +297,20 @@
default: return NSColor.systemGray
}
}
+
+ static func nsColor(for index: Int, customColor: TagCustomColor?) -> NSColor {
+ customColor?.nsColor ?? nsColor(for: index)
+ }
+
+ static func prefersDarkText(for color: NSColor) -> Bool {
+ let rgbColor = color.usingColorSpace(.sRGB) ?? color
+ let red = Double(rgbColor.redComponent)
+ let green = Double(rgbColor.greenComponent)
+ let blue = Double(rgbColor.blueComponent)
+ let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
+ return luminance > 0.62
+ }
+
static let allIndices: [Int] = [1, 2, 3, 4, 5, 6, 7]
}
@@ -705,27 +818,86 @@
apps: [AppInfo],
nameOverrides: [String: String] = [:],
defaultGroupName: String = "Other",
- tagOrder: [String] = []
+ tagOrder: [String] = [],
+ tagDefinitions: [String: TagDatabase.TagDef]? = nil,
+ containerAppOrder: [String: [String]]? = nil
) -> [TagGroup] {
let macCategory = "Mac自带"
+ let loadedStore = (tagDefinitions == nil || containerAppOrder == nil) ? TagDatabase.load() : nil
+ let effectiveTagDefinitions = tagDefinitions ?? loadedStore?.tags ?? [:]
+ var appTagsByPath: [String: [String]] = [:]
+ var validAppPaths = Set<String>()
+ for app in apps {
+ let path = app.path.path
+ validAppPaths.insert(path)
+ appTagsByPath[path] = uniqueOrdered((appTagsByPath[path] ?? []) + app.tags)
+ }
+ let effectiveContainerAppOrder = TagDatabase.normalizedContainerAppOrder(
+ containerAppOrder ?? loadedStore?.containerAppOrder ?? [:],
+ tags: effectiveTagDefinitions,
+ appTags: appTagsByPath,
+ validAppPaths: validAppPaths
+ )
var dict: [String: [AppInfo]] = [:]
+ var containerIDsByGroupName: [String: String] = [:]
var seenAppIDsByGroup: [String: Set<URL>] = [:]
for app in apps {
if app.isAppleApp {
- appendGroupedApp(app, to: macCategory, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
+ appendGroupedApp(
+ app,
+ to: macCategory,
+ containerID: AppContainerID.appleBuiltIn,
+ groups: &dict,
+ containerIDsByGroupName: &containerIDsByGroupName,
+ seenAppIDsByGroup: &seenAppIDsByGroup
+ )
}
if app.tags.isEmpty {
if !app.isAppleApp {
- appendGroupedApp(app, to: defaultGroupName, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
+ appendGroupedApp(
+ app,
+ to: defaultGroupName,
+ containerID: AppContainerID.uncategorized,
+ groups: &dict,
+ containerIDsByGroupName: &containerIDsByGroupName,
+ seenAppIDsByGroup: &seenAppIDsByGroup
+ )
}
} else {
for tag in uniqueOrdered(app.tags) {
let displayName = nameOverrides[tag] ?? tag
guard displayName != macCategory else { continue }
- appendGroupedApp(app, to: displayName, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
+ appendGroupedApp(
+ app,
+ to: displayName,
+ containerID: AppContainerID.forTag(tag, definition: effectiveTagDefinitions[tag]),
+ groups: &dict,
+ containerIDsByGroupName: &containerIDsByGroupName,
+ seenAppIDsByGroup: &seenAppIDsByGroup
+ )
}
+ }
+ }
+
+ for tagName in orderedTagDefinitionNames(
+ tagDefinitions: effectiveTagDefinitions,
+ tagOrder: tagOrder
+ ) {
+ let displayName = nameOverrides[tagName] ?? tagName
+ guard displayName != macCategory,
+ displayName != defaultGroupName
+ else { continue }
+
+ if containerIDsByGroupName[displayName] == nil {
+ containerIDsByGroupName[displayName] = AppContainerID.forTag(
+ tagName,
+ definition: effectiveTagDefinitions[tagName]
+ )
+ }
+ if dict[displayName] == nil {
+ dict[displayName] = []
}
}
@@ -747,16 +919,72 @@
if li != ri { return li < ri }
return lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending
}
- .map { TagGroup(name: $0.key, apps: $0.value) }
+ .map { groupName, apps in
+ let containerID = containerIDsByGroupName[groupName] ?? AppContainerID.forLegacyGroupName(groupName)
+ return TagGroup(
+ containerID: containerID,
+ name: groupName,
+ apps: orderedApps(
+ apps,
+ inContainer: containerID,
+ containerAppOrder: effectiveContainerAppOrder
+ )
+ )
+ }
+ }
+
+ static func orderedApps(
+ _ apps: [AppInfo],
+ inContainer containerID: String,
+ containerAppOrder: [String: [String]]
+ ) -> [AppInfo] {
+ applyAppOrder(
+ apps: apps,
+ order: containerAppOrder[containerID] ?? []
+ )
+ }
+
+ static func applyAppOrder(apps: [AppInfo], order: [String]) -> [AppInfo] {
+ let normalizedOrder = TagDatabase.normalizedAppOrderPaths(order)
+ guard !apps.isEmpty else { return [] }
+
+ var appsByPath: [String: AppInfo] = [:]
+ for app in apps where appsByPath[app.path.path] == nil {
+ appsByPath[app.path.path] = app
+ }
+
+ let ordered = normalizedOrder.compactMap { appsByPath[$0] }
+ let orderedPaths = Set(ordered.map { $0.path.path })
+ let remaining = apps
+ .filter { !orderedPaths.contains($0.path.path) }
+ .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
+ return ordered + remaining
+ }
+
+ private static func orderedTagDefinitionNames(
+ tagDefinitions: [String: TagDatabase.TagDef],
+ tagOrder: [String]
+ ) -> [String] {
+ let ordered = tagOrder.filter { tagDefinitions[$0] != nil }
+ let orderedSet = Set(ordered)
+ let remaining = tagDefinitions.keys
+ .filter { !orderedSet.contains($0) }
+ .sorted()
+ return ordered + remaining
}
private static func appendGroupedApp(
_ app: AppInfo,
to groupName: String,
+ containerID: String,
groups: inout [String: [AppInfo]],
+ containerIDsByGroupName: inout [String: String],
seenAppIDsByGroup: inout [String: Set<URL>]
) {
if seenAppIDsByGroup[groupName, default: []].insert(app.id).inserted {
+ if containerIDsByGroupName[groupName] == nil {
+ containerIDsByGroupName[groupName] = containerID
+ }
groups[groupName, default: []].append(app)
}
}
@@ -795,6 +1023,8 @@
struct TagDef: Codable, Equatable {
var color: Int
var systemCategoryID: SmartCategoryID? = nil
+ var customColor: TagCustomColor? = nil
+ var isCustomMaintained: Bool? = nil
}
struct Store: Codable {
@@ -802,6 +1032,7 @@
var tags: [String: TagDef] = [:]
var appTags: [String: [String]] = [:] // path → tag names
var tagOrder: [String] = [] // display order; empty → alpha sort
+ var containerAppOrder: [String: [String]] = [:] // stable container ID → ordered app paths
var uncommonAppPaths: [String] = [] // special marker; does not affect normal groups
var uncommonSources: [String: UncommonSource] = [:] // current uncommon source: auto/manual
var appOpenCounts: [String: Int] = [:] // launches opened from TagLauncher
@@ -818,6 +1049,7 @@
case tags
case appTags
case tagOrder
+ case containerAppOrder
case uncommonAppPaths
case uncommonSources
case appOpenCounts
@@ -838,6 +1070,10 @@
tags = try container.decodeIfPresent([String: TagDef].self, forKey: .tags) ?? [:]
appTags = try container.decodeIfPresent([String: [String]].self, forKey: .appTags) ?? [:]
tagOrder = try container.decodeIfPresent([String].self, forKey: .tagOrder) ?? []
+ containerAppOrder = try container.decodeIfPresent(
+ [String: [String]].self,
+ forKey: .containerAppOrder
+ ) ?? [:]
uncommonAppPaths = try container.decodeIfPresent([String].self, forKey: .uncommonAppPaths) ?? []
uncommonSources = try container.decodeIfPresent([String: UncommonSource].self, forKey: .uncommonSources) ?? [:]
appOpenCounts = try container.decodeIfPresent([String: Int].self, forKey: .appOpenCounts) ?? [:]
@@ -858,6 +1094,11 @@
for path in uncommonAppPaths where uncommonSources[path] == nil {
uncommonSources[path] = .manual
}
+ containerAppOrder = TagDatabase.normalizedContainerAppOrder(
+ containerAppOrder,
+ tags: tags,
+ appTags: appTags
+ )
}
var hasUserTagAssignments: Bool {
@@ -911,8 +1152,11 @@
static func load() -> Store {
guard let data = try? Data(contentsOf: storeURL),
- let store = try? JSONDecoder().decode(Store.self, from: data)
+ var store = try? JSONDecoder().decode(Store.self, from: data)
else { return Store() }
+ if normalizeContainerAppOrder(in: &store) {
+ save(store)
+ }
return store
}
@@ -925,10 +1169,190 @@
}
static func save(_ store: Store) {
+ var normalizedStore = store
+ _ = normalizeContainerAppOrder(in: &normalizedStore)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- guard let data = try? encoder.encode(store) else { return }
+ guard let data = try? encoder.encode(normalizedStore) else { return }
try? data.write(to: storeURL, options: .atomic)
+ }
+
+ static func customMaintainedContainerCount(in store: Store? = nil) -> Int {
+ customMaintainedContainerNames(in: store).count
+ }
+
+ static func customMaintainedContainerNames(in store: Store? = nil) -> [String] {
+ let effectiveStore = store ?? load()
+ return effectiveStore.tags
+ .filter { isCustomMaintainedTag(name: $0.key, definition: $0.value) }
+ .map(\.key)
+ .sorted()
+ }
+
+ static func isCustomMaintainedTag(name: String, definition: TagDef) -> Bool {
+ guard let systemCategoryID = definition.systemCategoryID else {
+ return true
+ }
+ if definition.isCustomMaintained == true {
+ return true
+ }
+ if definition.customColor != nil {
+ return true
+ }
+ return definition.color != systemCategoryID.defaultColorIndex
+ }
+
+ static func markCustomMaintainedIfNeeded(_ tagName: String, in store: inout Store) {
+ guard var definition = store.tags[tagName],
+ definition.systemCategoryID != nil,
+ !isCustomMaintainedTag(name: tagName, definition: definition)
+ else { return }
+ definition.isCustomMaintained = true
+ store.tags[tagName] = definition
+ }
+
+ static func normalizedAppOrderPaths(_ paths: [String]) -> [String] {
+ var seen = Set<String>()
+ return paths.compactMap { rawPath in
+ let path = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !path.isEmpty, seen.insert(path).inserted else { return nil }
+ return path
+ }
+ }
+
+ static func normalizedContainerID(_ rawValue: String, tags: [String: TagDef] = [:]) -> String {
+ let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty else { return "" }
+ if value == AppContainerID.uncategorized
+ || value == AppContainerID.appleBuiltIn
+ || value.hasPrefix("tag:")
+ || value.hasPrefix("system:") {
+ return value
+ }
+ if value == "Other" || value == tr("group.uncategorized") {
+ return AppContainerID.uncategorized
+ }
+ if value == "Mac自带" || value == tr("group.appleBuiltIn") {
+ return AppContainerID.appleBuiltIn
+ }
+ return AppContainerID.forTag(value, definition: tags[value])
+ }
+
+ static func normalizedContainerAppOrder(
+ _ order: [String: [String]],
+ tags: [String: TagDef] = [:],
+ appTags: [String: [String]]? = nil,
+ validAppPaths: Set<String>? = nil
+ ) -> [String: [String]] {
+ var normalized: [String: [String]] = [:]
+ for (rawContainerID, rawPaths) in order {
+ let containerID = normalizedContainerID(rawContainerID, tags: tags)
+ guard !containerID.isEmpty,
+ isContainerOrderAllowed(containerID, tags: tags)
+ else { continue }
+
+ var paths = normalizedAppOrderPaths(rawPaths)
+ if let validAppPaths {
+ paths = paths.filter { validAppPaths.contains($0) }
+ }
+ if let appTags {
+ paths = paths.filter {
+ pathBelongsToContainer(
+ path: $0,
+ containerID: containerID,
+ tags: tags,
+ appTags: appTags
+ )
+ }
+ }
+ guard !paths.isEmpty else { continue }
+
+ if let existing = normalized[containerID] {
+ normalized[containerID] = normalizedAppOrderPaths(existing + paths)
+ } else {
+ normalized[containerID] = paths
+ }
+ }
+ return normalized
+ }
+
+ @discardableResult
+ static func normalizeContainerAppOrder(
+ in store: inout Store,
+ validAppPaths: Set<String>? = nil
+ ) -> Bool {
+ let original = store.containerAppOrder
+ store.containerAppOrder = normalizedContainerAppOrder(
+ store.containerAppOrder,
+ tags: store.tags,
+ appTags: store.appTags,
+ validAppPaths: validAppPaths
+ )
+ return original != store.containerAppOrder
+ }
+
+ static func migrateContainerAppOrderKey(
+ in store: inout Store,
+ from oldContainerID: String,
+ to newContainerID: String
+ ) {
+ guard oldContainerID != newContainerID else { return }
+ let oldKey = normalizedContainerID(oldContainerID, tags: store.tags)
+ let newKey = normalizedContainerID(newContainerID, tags: store.tags)
+ guard !oldKey.isEmpty, !newKey.isEmpty,
+ let oldOrder = store.containerAppOrder.removeValue(forKey: oldKey)
+ else { return }
+
+ let mergedOrder = (store.containerAppOrder[newKey] ?? []) + oldOrder
+ store.containerAppOrder[newKey] = normalizedAppOrderPaths(mergedOrder)
+ }
+
+ static func removeContainerAppOrder(
+ in store: inout Store,
+ containerID: String
+ ) {
+ let key = normalizedContainerID(containerID, tags: store.tags)
+ guard !key.isEmpty else { return }
+ store.containerAppOrder.removeValue(forKey: key)
+ }
+
+ private static func isContainerOrderAllowed(_ containerID: String, tags: [String: TagDef]) -> Bool {
+ if containerID == AppContainerID.uncategorized || containerID == AppContainerID.appleBuiltIn {
+ return true
+ }
+ if containerID.hasPrefix("tag:") {
+ let tagName = String(containerID.dropFirst("tag:".count))
+ return tags[tagName] != nil
+ }
+ if containerID.hasPrefix("system:") {
+ let rawValue = String(containerID.dropFirst("system:".count))
+ guard let categoryID = SmartCategoryID(rawValue: rawValue) else { return false }
+ return tags.values.contains { $0.systemCategoryID == categoryID }
+ }
+ return false
+ }
+
+ private static func pathBelongsToContainer(
+ path: String,
+ containerID: String,
+ tags: [String: TagDef],
+ appTags: [String: [String]]
+ ) -> Bool {
+ if containerID == AppContainerID.uncategorized || containerID == AppContainerID.appleBuiltIn {
+ return true
+ }
+ if containerID.hasPrefix("tag:") {
+ let tagName = String(containerID.dropFirst("tag:".count))
+ return appTags[path]?.contains(tagName) == true
+ }
+ if containerID.hasPrefix("system:") {
+ let rawValue = String(containerID.dropFirst("system:".count))
+ guard let categoryID = SmartCategoryID(rawValue: rawValue),
+ let tagName = tags.first(where: { $0.value.systemCategoryID == categoryID })?.key
+ else { return false }
+ return appTags[path]?.contains(tagName) == true
+ }
+ return false
}
static func noteFingerprint(_ value: String) -> String {
@@ -969,6 +1393,7 @@
let tags: [String: TagDef]
let appTags: [String: [String]]
let tagOrder: [String]
+ let containerAppOrder: [String: [String]]
let uncommonAppPaths: [String]
let uncommonSources: [String: UncommonSource]
let disabledSystemCategoryIDs: [SmartCategoryID]
@@ -1085,6 +1510,11 @@
tags: store.tags,
appTags: store.appTags.mapValues(uniqueOrdered),
tagOrder: uniqueOrdered(store.tagOrder),
+ containerAppOrder: normalizedContainerAppOrder(
+ store.containerAppOrder,
+ tags: store.tags,
+ appTags: store.appTags
+ ),
uncommonAppPaths: store.uncommonAppPaths.sorted(),
uncommonSources: store.uncommonSources,
disabledSystemCategoryIDs: store.disabledSystemCategoryIDs
@@ -1133,6 +1563,7 @@
for (tagName, tagDef) in store.tags {
guard let categoryID = tagDef.systemCategoryID else { continue }
+ guard !isCustomMaintainedTag(name: tagName, definition: tagDef) else { continue }
let targetName = categoryID.localizedDisplayName
guard targetName != tagName else { continue }
if let existing = store.tags[targetName],
@@ -1160,7 +1591,9 @@
) -> (name: String, created: Bool)? {
if let existingName = systemTagName(for: categoryID, in: store) {
let targetName = categoryID.localizedDisplayName
- if existingName != targetName {
+ let existingDefinition = store.tags[existingName]
+ if existingName != targetName,
+ existingDefinition.map({ !isCustomMaintainedTag(name: existingName, definition: $0) }) ?? true {
if let existing = store.tags[targetName],
existing.systemCategoryID != categoryID {
return (existingName, false)
@@ -1192,6 +1625,8 @@
private static func renameTagKey(in store: inout Store, from oldName: String, to newName: String) {
guard oldName != newName, let tagDef = store.tags.removeValue(forKey: oldName) else { return }
+ let oldContainerID = AppContainerID.forTag(oldName, definition: tagDef)
+ let newContainerID = AppContainerID.forTag(newName, definition: tagDef)
store.tags[newName] = tagDef
store.tagOrder = uniqueOrdered(store.tagOrder.map { $0 == oldName ? newName : $0 })
@@ -1203,6 +1638,8 @@
store.appTags[path] = renamedTags
}
}
+ migrateContainerAppOrderKey(in: &store, from: oldContainerID, to: newContainerID)
+ _ = normalizeContainerAppOrder(in: &store)
}
private static func uniqueOrdered(_ values: [String]) -> [String] {
@@ -1213,12 +1650,14 @@
// MARK: Export / Import
static func exportTo(_ url: URL) throws {
+ try ProEntitlementPolicy.requireUnlocked(.layoutExport)
let store = loadWithEnsuredCategoryScheme()
let data = try JSONEncoder().encode(store)
try data.write(to: url, options: .atomic)
}
static func importFrom(_ url: URL) throws -> Store {
+ try ProEntitlementPolicy.requireUnlocked(.layoutImport)
let previousStore = loadWithEnsuredCategoryScheme()
let data = try Data(contentsOf: url)
var store = try JSONDecoder().decode(Store.self, from: data)
@@ -1239,6 +1678,7 @@
let previousStore = loadWithEnsuredCategoryScheme()
var store = previousStore
store.appTags = [:]
+ store.containerAppOrder = [:]
saveUserCategorySchemeMutation(
store,
previous: previousStore,
@@ -1414,12 +1854,11 @@
guard !FileManager.default.fileExists(atPath: storeURL.path) else { return }
let starterCategoryIDs: [SmartCategoryID] = [
- .uiPrototyping,
- .ide,
- .writing,
- .game,
+ .design,
+ .development,
+ .office,
.entertainment,
- .system,
+ .systemEnhancement,
.gtd
]
@@ -1431,9 +1870,55 @@
}
}
+enum TagEditorMutationResult {
+ case saved
+ case noChange
+ case blockedCustomContainerQuota(ProCustomContainerQuotaStatus)
+
+ var didSave: Bool {
+ if case .saved = self {
+ return true
+ }
+ return false
+ }
+}
+
// MARK: - Tag Editor (CRUD against local TagDatabase)
enum TagEditor {
+ private static func customContainerCandidates(
+ _ tagNames: Set<String>,
+ in store: TagDatabase.Store
+ ) -> Set<String> {
+ Set(tagNames.filter { tagName in
+ guard let definition = store.tags[tagName] else {
+ return true
+ }
+ return !TagDatabase.isCustomMaintainedTag(name: tagName, definition: definition)
+ })
+ }
+
+ private static func customContainerMaintenanceResult(
+ for tagNames: Set<String>,
+ in store: TagDatabase.Store
+ ) -> TagEditorMutationResult? {
+ let addedCount = customContainerCandidates(tagNames, in: store).count
+ switch ProEntitlementPolicy.customContainerMaintenanceDecision(
+ addingCustomContainerCount: addedCount,
+ in: store
+ ) {
+ case .allow:
+ return nil
+ case .blocked(let status):
+ return .blockedCustomContainerQuota(status)
+ }
+ }
+
+ private static func markCustomMaintained(_ tagNames: Set<String>, in store: inout TagDatabase.Store) {
+ for tagName in tagNames {
+ TagDatabase.markCustomMaintainedIfNeeded(tagName, in: &store)
+ }
+ }
// MARK: Read
@@ -1446,13 +1931,19 @@
/// Tag names in display order. Falls back to alpha sort if no custom order.
static func orderedTagNames() -> [String] {
let store = TagDatabase.load()
+ return orderedTagNames(in: store)
+ }
+
+ static func orderedTagNames(in store: TagDatabase.Store) -> [String] {
let ordered = store.tagOrder.filter { store.tags[$0] != nil }
let remaining = store.tags.keys.filter { !ordered.contains($0) }.sorted()
return ordered + remaining
}
/// Persist a new tag display order.
- static func reorderTags(_ names: [String]) {
+ @discardableResult
+ static func reorderTags(_ names: [String]) -> Bool {
+ guard ProEntitlementPolicy.isUnlocked(.persistentAppSorting) else { return false }
var store = TagDatabase.load()
let previousStore = store
store.tagOrder = names
@@ -1461,12 +1952,44 @@
previous: previousStore,
reason: "reorder-tags"
)
+ return true
+ }
+
+ /// Persist the visible app order for one stable container.
+ static func reorderApps(inContainer containerID: String, orderedPaths: [String]) {
+ guard ProEntitlementPolicy.isUnlocked(.persistentAppSorting) else { return }
+
+ var store = TagDatabase.load()
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
+ let previousStore = store
+
+ let key = TagDatabase.normalizedContainerID(containerID, tags: store.tags)
+ guard !key.isEmpty else { return }
+
+ let normalizedPaths = TagDatabase.normalizedAppOrderPaths(orderedPaths)
+ if normalizedPaths.isEmpty {
+ store.containerAppOrder.removeValue(forKey: key)
+ } else {
+ store.containerAppOrder[key] = normalizedPaths
+ }
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
+
+ guard store.containerAppOrder != previousStore.containerAppOrder else { return }
+ TagDatabase.saveUserCategorySchemeMutation(
+ store,
+ previous: previousStore,
+ reason: "reorder-apps"
+ )
}
/// Create a new tag definition (no app assignments yet).
- static func createTag(_ name: String, color: Int) {
+ @discardableResult
+ static func createTag(_ name: String, color: Int) -> TagEditorMutationResult {
var store = TagDatabase.load()
- guard store.tags[name] == nil else { return }
+ guard store.tags[name] == nil else { return .noChange }
+ if let blocked = customContainerMaintenanceResult(for: [name], in: store) {
+ return blocked
+ }
let previousStore = store
store.tags[name] = TagDatabase.TagDef(color: color, systemCategoryID: nil)
if !store.tagOrder.contains(name) { store.tagOrder.insert(name, at: 0) }
@@ -1475,6 +1998,7 @@
previous: previousStore,
reason: "create-tag"
)
+ return .saved
}
/// Annotate scanned apps with tags from the database.
@@ -1503,8 +2027,12 @@
// MARK: Write
/// Assign a tag to multiple apps. Preserves existing tags.
- static func assignTag(_ tag: String, color: Int, to paths: [String]) {
+ @discardableResult
+ static func assignTag(_ tag: String, color: Int, to paths: [String]) -> TagEditorMutationResult {
var store = TagDatabase.load()
+ if let blocked = customContainerMaintenanceResult(for: [tag], in: store) {
+ return blocked
+ }
let previousStore = store
// Ensure tag definition exists
if store.tags[tag] == nil {
@@ -1512,6 +2040,7 @@
// New tag: append to display order
if !store.tagOrder.contains(tag) { store.tagOrder.insert(tag, at: 0) }
}
+ markCustomMaintained([tag], in: &store)
for path in paths {
var current = store.appTags[path] ?? []
if !current.contains(tag) {
@@ -1524,14 +2053,27 @@
previous: previousStore,
reason: "assign-tag"
)
+ return .saved
}
/// Replace the full editable tag set for multiple apps.
- static func setTags(_ tags: [String], to paths: [String]) {
+ @discardableResult
+ static func setTags(_ tags: [String], to paths: [String]) -> TagEditorMutationResult {
var store = TagDatabase.load()
- let previousStore = store
let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
let validTags = tags.filter { store.tags[$0] != nil }
+ let nextValidTags = Set(validTags)
+ var touchedTags = Set<String>()
+ for path in paths {
+ let currentTags = Set(store.appTags[path] ?? [])
+ touchedTags.formUnion(currentTags.symmetricDifference(nextValidTags))
+ }
+ touchedTags = touchedTags.filter { store.tags[$0] != nil }
+ if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
+ return blocked
+ }
+ let previousStore = store
+ markCustomMaintained(touchedTags, in: &store)
var uncommonPaths = Set(store.uncommonAppPaths)
for path in paths {
if validTags.isEmpty {
@@ -1553,16 +2095,29 @@
previous: previousStore,
reason: "set-tags"
)
+ return .saved
}
/// Append tags to multiple apps without disturbing their existing tag sets.
- static func appendTags(_ tags: [String], to paths: [String]) {
- guard !tags.isEmpty, !paths.isEmpty else { return }
+ @discardableResult
+ static func appendTags(_ tags: [String], to paths: [String]) -> TagEditorMutationResult {
+ guard !tags.isEmpty, !paths.isEmpty else { return .noChange }
var store = TagDatabase.load()
- let previousStore = store
let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
let validTags = tags.filter { store.tags[$0] != nil }
+ var touchedTags = Set<String>()
+ for path in paths {
+ let current = Set(store.appTags[path] ?? [])
+ for tag in validTags where !current.contains(tag) {
+ touchedTags.insert(tag)
+ }
+ }
+ if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
+ return blocked
+ }
+ let previousStore = store
+ markCustomMaintained(touchedTags, in: &store)
var uncommonPaths = Set(store.uncommonAppPaths)
for path in paths {
@@ -1586,16 +2141,27 @@
previous: previousStore,
reason: "append-tags"
)
+ return .saved
}
/// Remove tags from multiple apps while preserving every unrelated tag.
- static func removeTags(_ tags: [String], from paths: [String]) {
- guard !tags.isEmpty, !paths.isEmpty else { return }
+ @discardableResult
+ static func removeTags(_ tags: [String], from paths: [String]) -> TagEditorMutationResult {
+ guard !tags.isEmpty, !paths.isEmpty else { return .noChange }
var store = TagDatabase.load()
- let previousStore = store
let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
let validTags = Set(tags.filter { store.tags[$0] != nil })
+ var touchedTags = Set<String>()
+ for path in paths {
+ let current = Set(store.appTags[path] ?? [])
+ touchedTags.formUnion(validTags.intersection(current))
+ }
+ if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
+ return blocked
+ }
+ let previousStore = store
+ markCustomMaintained(touchedTags, in: &store)
var uncommonPaths = Set(store.uncommonAppPaths)
for path in paths {
@@ -1620,6 +2186,7 @@
previous: previousStore,
reason: "remove-tags"
)
+ return .saved
}
static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store {
@@ -1630,8 +2197,12 @@
if store.knownAppPaths.isEmpty {
let seededDefaultNotes = seedDefaultAppleAppNotes(for: apps, in: &store)
let markedUncommon = markUnfamiliarAppleAppsAsUncommon(apps, in: &store)
+ let normalizedOrder = TagDatabase.normalizeContainerAppOrder(
+ in: &store,
+ validAppPaths: scannedPaths
+ )
store.knownAppPaths = scannedPaths.sorted()
- if apps.isEmpty == false || seededDefaultNotes || markedUncommon {
+ if apps.isEmpty == false || seededDefaultNotes || markedUncommon || normalizedOrder {
TagDatabase.save(store)
}
return store
@@ -1646,9 +2217,13 @@
}
store.knownAppPaths = knownPaths.subtracting(removedPaths).sorted()
}
+ let normalizedOrder = TagDatabase.normalizeContainerAppOrder(
+ in: &store,
+ validAppPaths: scannedPaths
+ )
guard !newPaths.isEmpty else {
- if !removedPaths.isEmpty {
+ if !removedPaths.isEmpty || normalizedOrder {
TagDatabase.save(store)
}
return store
@@ -1673,6 +2248,10 @@
_ = seedDefaultAppleAppNotes(for: newApps, in: &store)
store.uncommonAppPaths = uncommonPaths.sorted()
store.knownAppPaths = Set(store.knownAppPaths).union(newPaths).sorted()
+ _ = TagDatabase.normalizeContainerAppOrder(
+ in: &store,
+ validAppPaths: scannedPaths
+ )
TagDatabase.save(store)
return store
}
@@ -1757,11 +2336,22 @@
TagDatabase.save(store)
}
- static func setAppNote(_ note: String, for path: String) {
+ @discardableResult
+ static func setAppNote(_ note: String, for path: String) -> ProNoteSaveDecision {
var store = TagDatabase.load()
- let previousStore = store
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
let limited = String(trimmed.prefix(TagDatabase.maxAppNoteLength))
+ let decision = ProEntitlementPolicy.noteSaveDecision(
+ note: limited,
+ for: path,
+ in: store
+ )
+
+ guard case .allow = decision else {
+ return decision
+ }
+
+ let previousStore = store
if limited.isEmpty {
store.appNotes.removeValue(forKey: path)
store.appNoteMetadata[path] = TagDatabase.AppNoteMetadata(
@@ -1787,15 +2377,33 @@
previous: previousStore,
reason: "edit-app-note"
)
+ return decision
}
- static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) {
+ @discardableResult
+ static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) -> TagEditorMutationResult {
var store = TagDatabase.load()
+ var touchedTags = Set<String>()
+ let currentTags = Set(store.appTags[path] ?? [])
+ if store.tags[targetTag] == nil || !currentTags.contains(targetTag) {
+ touchedTags.insert(targetTag)
+ }
+ if !copy,
+ !sourceTag.isEmpty,
+ sourceTag != "Mac自带",
+ sourceTag != tr("group.appleBuiltIn"),
+ currentTags.contains(sourceTag) {
+ touchedTags.insert(sourceTag)
+ }
+ if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
+ return blocked
+ }
let previousStore = store
if store.tags[targetTag] == nil {
store.tags[targetTag] = TagDatabase.TagDef(color: color, systemCategoryID: nil)
if !store.tagOrder.contains(targetTag) { store.tagOrder.insert(targetTag, at: 0) }
}
+ markCustomMaintained(touchedTags, in: &store)
var current = store.appTags[path] ?? []
if !copy, !sourceTag.isEmpty, sourceTag != "Mac自带", sourceTag != tr("group.appleBuiltIn") {
@@ -1815,16 +2423,37 @@
previous: previousStore,
reason: "move-app"
)
+ return .saved
}
/// Rename a tag on all apps that have it.
- static func renameTag(from oldName: String, to newName: String) {
+ @discardableResult
+ static func renameTag(from oldName: String, to newName: String) -> TagEditorMutationResult {
var store = TagDatabase.load()
- guard oldName != newName, store.tags[newName] == nil else { return }
+ guard oldName != newName, store.tags[newName] == nil else { return .noChange }
+ guard let oldDefinition = store.tags[oldName] else { return .noChange }
+ let needsCustomContainerSlot = !TagDatabase.isCustomMaintainedTag(
+ name: oldName,
+ definition: oldDefinition
+ )
+ if needsCustomContainerSlot,
+ let blocked = customContainerMaintenanceResult(for: [oldName], in: store) {
+ return blocked
+ }
let previousStore = store
// Update tag definition
- if let def = store.tags.removeValue(forKey: oldName) {
+ if var def = store.tags.removeValue(forKey: oldName) {
+ let oldContainerID = AppContainerID.forTag(oldName, definition: def)
+ let newContainerID = AppContainerID.forTag(newName, definition: def)
+ if def.systemCategoryID != nil {
+ def.isCustomMaintained = true
+ }
store.tags[newName] = def
+ TagDatabase.migrateContainerAppOrderKey(
+ in: &store,
+ from: oldContainerID,
+ to: newContainerID
+ )
}
store.tagOrder = store.tagOrder.map { $0 == oldName ? newName : $0 }
// Update all app assignments
@@ -1834,21 +2463,34 @@
store.appTags[path] = Array(NSOrderedSet(array: tags).compactMap { $0 as? String })
}
}
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
reason: "rename-tag"
)
+ return .saved
}
/// Delete a tag completely (from all apps and tag definitions).
static func deleteTagCompletely(_ tag: String) {
var store = TagDatabase.load()
let previousStore = store
- if let removedTag = store.tags.removeValue(forKey: tag),
- let categoryID = removedTag.systemCategoryID,
+ let removedTag = store.tags.removeValue(forKey: tag)
+ if let categoryID = removedTag?.systemCategoryID,
!store.disabledSystemCategoryIDs.contains(categoryID) {
store.disabledSystemCategoryIDs.append(categoryID)
+ }
+ if let removedTag {
+ TagDatabase.removeContainerAppOrder(
+ in: &store,
+ containerID: AppContainerID.forTag(tag, definition: removedTag)
+ )
+ } else {
+ TagDatabase.removeContainerAppOrder(
+ in: &store,
+ containerID: AppContainerID.tag(tag)
+ )
}
store.tagOrder.removeAll { $0 == tag }
for (path, var tags) in store.appTags {
@@ -1859,6 +2501,7 @@
store.appTags[path] = tags
}
}
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
@@ -1867,16 +2510,47 @@
}
/// Set the color for a tag.
- static func setColor(_ color: Int, for tag: String) {
+ @discardableResult
+ static func setColor(_ color: Int, for tag: String) -> TagEditorMutationResult {
var store = TagDatabase.load()
- let previousStore = store
- var tagDef = store.tags[tag] ?? TagDatabase.TagDef(color: color, systemCategoryID: nil)
+ let existingDefinition = store.tags[tag]
+ var tagDef = existingDefinition ?? TagDatabase.TagDef(color: color, systemCategoryID: nil)
+ let wasCustomMaintained = existingDefinition.map {
+ TagDatabase.isCustomMaintainedTag(name: tag, definition: $0)
+ } ?? false
tagDef.color = color
+ tagDef.customColor = nil
+ let willBeCustomMaintained = TagDatabase.isCustomMaintainedTag(name: tag, definition: tagDef)
+ if !wasCustomMaintained, willBeCustomMaintained,
+ let blocked = customContainerMaintenanceResult(for: [tag], in: store) {
+ return blocked
+ }
+ let previousStore = store
store.tags[tag] = tagDef
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
reason: "set-tag-color"
)
+ return .saved
+ }
+
+ /// Set a Pro custom color for a tag without changing its fallback base color.
+ @discardableResult
+ static func setCustomColor(_ customColor: TagCustomColor, for tag: String) -> Bool {
+ guard ProEntitlementPolicy.isUnlocked(.customTagColors) else {
+ return false
+ }
+ var store = TagDatabase.load()
+ let previousStore = store
+ var tagDef = store.tags[tag] ?? TagDatabase.TagDef(color: 0, systemCategoryID: nil)
+ tagDef.customColor = customColor
+ store.tags[tag] = tagDef
+ TagDatabase.saveUserCategorySchemeMutation(
+ store,
+ previous: previousStore,
+ reason: "set-tag-custom-color"
+ )
+ return true
}
}
--
Gitblit v1.9.3