| | |
| | | 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) |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | 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] |
| | | } |
| | | |
| | |
| | | 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] = [] |
| | | } |
| | | } |
| | | |
| | |
| | | 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) |
| | | } |
| | | } |
| | |
| | | struct TagDef: Codable, Equatable { |
| | | var color: Int |
| | | var systemCategoryID: SmartCategoryID? = nil |
| | | var customColor: TagCustomColor? = nil |
| | | var isCustomMaintained: Bool? = nil |
| | | } |
| | | |
| | | struct Store: Codable { |
| | |
| | | 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 |
| | |
| | | case tags |
| | | case appTags |
| | | case tagOrder |
| | | case containerAppOrder |
| | | case uncommonAppPaths |
| | | case uncommonSources |
| | | case appOpenCounts |
| | |
| | | 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) ?? [:] |
| | |
| | | for path in uncommonAppPaths where uncommonSources[path] == nil { |
| | | uncommonSources[path] = .manual |
| | | } |
| | | containerAppOrder = TagDatabase.normalizedContainerAppOrder( |
| | | containerAppOrder, |
| | | tags: tags, |
| | | appTags: appTags |
| | | ) |
| | | } |
| | | |
| | | var hasUserTagAssignments: Bool { |
| | |
| | | |
| | | 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 |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | 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] |
| | |
| | | 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 |
| | |
| | | |
| | | 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], |
| | |
| | | ) -> (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) |
| | |
| | | |
| | | 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 }) |
| | | |
| | |
| | | store.appTags[path] = renamedTags |
| | | } |
| | | } |
| | | migrateContainerAppOrderKey(in: &store, from: oldContainerID, to: newContainerID) |
| | | _ = normalizeContainerAppOrder(in: &store) |
| | | } |
| | | |
| | | private static func uniqueOrdered(_ values: [String]) -> [String] { |
| | |
| | | // 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) |
| | |
| | | let previousStore = loadWithEnsuredCategoryScheme() |
| | | var store = previousStore |
| | | store.appTags = [:] |
| | | store.containerAppOrder = [:] |
| | | saveUserCategorySchemeMutation( |
| | | store, |
| | | previous: previousStore, |
| | |
| | | guard !FileManager.default.fileExists(atPath: storeURL.path) else { return } |
| | | |
| | | let starterCategoryIDs: [SmartCategoryID] = [ |
| | | .uiPrototyping, |
| | | .ide, |
| | | .writing, |
| | | .game, |
| | | .design, |
| | | .development, |
| | | .office, |
| | | .entertainment, |
| | | .system, |
| | | .systemEnhancement, |
| | | .gtd |
| | | ] |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | |
| | |
| | | /// 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 |
| | |
| | | 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) } |
| | |
| | | previous: previousStore, |
| | | reason: "create-tag" |
| | | ) |
| | | return .saved |
| | | } |
| | | |
| | | /// Annotate scanned apps with tags from the database. |
| | |
| | | // 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 { |
| | |
| | | // 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) { |
| | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | previous: previousStore, |
| | | reason: "remove-tags" |
| | | ) |
| | | return .saved |
| | | } |
| | | |
| | | static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store { |
| | |
| | | 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 |
| | |
| | | } |
| | | 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 |
| | |
| | | _ = 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 |
| | | } |
| | |
| | | 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( |
| | |
| | | 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") { |
| | |
| | | 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 |
| | |
| | | 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 { |
| | |
| | | store.appTags[path] = tags |
| | | } |
| | | } |
| | | _ = TagDatabase.normalizeContainerAppOrder(in: &store) |
| | | TagDatabase.saveUserCategorySchemeMutation( |
| | | store, |
| | | previous: previousStore, |
| | |
| | | } |
| | | |
| | | /// 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 |
| | | } |
| | | } |