Ariver
2026-07-02 0f7ee6e29f70017dd4fdfe2dd3a6106f7204f04f
src/Apptag/DataLayer.swift
@@ -1024,6 +1024,7 @@
        var color: Int
        var systemCategoryID: SmartCategoryID? = nil
        var customColor: TagCustomColor? = nil
        var isCustomMaintained: Bool? = nil
    }
    struct Store: Codable {
@@ -1174,6 +1175,40 @@
        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] {
@@ -1528,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],
@@ -1555,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)
@@ -1832,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
@@ -1857,7 +1941,9 @@
    }
    /// 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
@@ -1866,6 +1952,7 @@
            previous: previousStore,
            reason: "reorder-tags"
        )
        return true
    }
    /// Persist the visible app order for one stable container.
@@ -1896,9 +1983,13 @@
    }
    /// 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) }
@@ -1907,6 +1998,7 @@
            previous: previousStore,
            reason: "create-tag"
        )
        return .saved
    }
    /// Annotate scanned apps with tags from the database.
@@ -1935,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 {
@@ -1944,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) {
@@ -1956,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 {
@@ -1985,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 {
@@ -2018,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 {
@@ -2052,6 +2186,7 @@
            previous: previousStore,
            reason: "remove-tags"
        )
        return .saved
    }
    static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store {
@@ -2245,13 +2380,30 @@
        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") {
@@ -2271,17 +2423,31 @@
            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,
@@ -2303,6 +2469,7 @@
            previous: previousStore,
            reason: "rename-tag"
        )
        return .saved
    }
    /// Delete a tag completely (from all apps and tag definitions).
@@ -2343,18 +2510,29 @@
    }
    /// 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.