| | |
| | | var color: Int |
| | | var systemCategoryID: SmartCategoryID? = nil |
| | | var customColor: TagCustomColor? = nil |
| | | var isCustomMaintained: Bool? = nil |
| | | } |
| | | |
| | | struct Store: Codable { |
| | |
| | | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] |
| | | 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] { |
| | |
| | | |
| | | 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) |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | |
| | |
| | | } |
| | | |
| | | /// 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. |
| | |
| | | } |
| | | |
| | | /// 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 { |
| | |
| | | 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, |
| | |
| | | previous: previousStore, |
| | | reason: "rename-tag" |
| | | ) |
| | | return .saved |
| | | } |
| | | |
| | | /// Delete a tag completely (from all apps and tag definitions). |
| | |
| | | } |
| | | |
| | | /// 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. |