Ariver
2026-06-14 bd792cd36d57b8f0741af9a6a64e59969cdce490
src/Apptag/DataLayer.swift
@@ -12,46 +12,18 @@
    let bundleIdentifier: String?
    let localizedNames: [String]
    let localizedNamesByLanguage: [String: String]
    let systemDisplayNames: [String]
    let bundleDisplayNames: [String]
    let icon: NSImage  // Pre-loaded during background scan
    var isUncommon: Bool = false
    var note: String? = nil
    var displayName: String {
        localizedDisplayName(for: L10n.currentCode)
        AppDisplayNameResolver.displayName(for: self, languageCode: L10n.currentCode)
    }
    func localizedDisplayName(for languageCode: String) -> String {
        let candidates = AppInfo.displayLanguageFallbacks(for: languageCode)
        for code in candidates {
            if let localizedName = localizedNamesByLanguage[code],
               !localizedName.isEmpty {
                return localizedName
            }
        }
        if let localizedName = localizedNames.first, !localizedName.isEmpty {
            return localizedName
        }
        return name
    }
    private static func displayLanguageFallbacks(for languageCode: String) -> [String] {
        var candidates = [languageCode]
        switch languageCode {
        case "zh-Hans":
            candidates.append("zh-Hant")
        case "zh-Hant":
            candidates.append("zh-Hans")
        case "nb":
            candidates.append("no")
        case "nn":
            candidates.append("no")
        case "no":
            candidates.append(contentsOf: ["nb", "nn"])
        default:
            break
        }
        candidates.append("en")
        return uniqueLanguageCodes(candidates)
        AppDisplayNameResolver.displayName(for: self, languageCode: languageCode)
    }
    fileprivate static func uniqueLanguageCodes(_ codes: [String]) -> [String] {
@@ -69,10 +41,198 @@
    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)
        if let localizedName = firstLocalizedName(
            for: languageFallbacks,
            in: app.localizedNamesByLanguage
        ) {
            return localizedName
        }
        if languageCode == "en",
           let englishBaseName = firstDisplayCandidate([app.name] + app.bundleDisplayNames) {
            return englishBaseName
        }
        if let systemDisplayName = firstDisplayCandidate(app.systemDisplayNames) {
            return systemDisplayName
        }
        if let englishName = firstLocalizedName(for: ["en"], in: app.localizedNamesByLanguage) {
            return englishName
        }
        if let bundleDisplayName = firstDisplayCandidate(app.bundleDisplayNames) {
            return bundleDisplayName
        }
        let skippedLanguageCodes = Set(languageFallbacks + ["en"])
        let remainingLocalizedNames = L10n.supported.compactMap { language -> String? in
            guard !skippedLanguageCodes.contains(language.code) else { return nil }
            return app.localizedNamesByLanguage[language.code]
        }
        if let localizedName = firstDisplayCandidate(remainingLocalizedNames) {
            return localizedName
        }
        if let alias = firstDisplayCandidate(app.localizedNames) {
            return alias
        }
        return app.name
    }
    static func searchAliases(for app: AppInfo) -> [String] {
        uniqueDisplayNames(
            L10n.supported.compactMap { app.localizedNamesByLanguage[$0.code] }
                + app.systemDisplayNames
                + app.bundleDisplayNames
                + app.localizedNames
                + [app.displayName],
            excluding: app.name
        )
    }
    private static func displayLanguageFallbacks(
        for languageCode: String,
        includeEnglish: Bool
    ) -> [String] {
        var candidates = [
            languageCode,
            languageCode.replacingOccurrences(of: "_", with: "-")
        ]
        switch languageCode {
        case "zh-Hans":
            candidates.append("zh-Hant")
        case "zh-Hant":
            candidates.append("zh-Hans")
        case "pt-BR":
            candidates.append("pt")
        case "sr-Cyrl":
            candidates.append("sr")
        case "ar-Najdi":
            candidates.append("ar")
        case "nb":
            candidates.append(contentsOf: ["no", "nn"])
        case "nn":
            candidates.append(contentsOf: ["no", "nb"])
        case "no":
            candidates.append(contentsOf: ["nb", "nn"])
        default:
            if let base = languageCode.split(separator: "-").first.map(String.init) {
                candidates.append(base)
            }
        }
        if includeEnglish {
            candidates.append("en")
        }
        return AppInfo.uniqueLanguageCodes(candidates)
    }
    private static func firstLocalizedName(
        for languageCodes: [String],
        in localizedNamesByLanguage: [String: String]
    ) -> String? {
        firstDisplayCandidate(languageCodes.compactMap { localizedNamesByLanguage[$0] })
    }
    private static func firstDisplayCandidate(_ values: [String]) -> String? {
        values.lazy.compactMap(normalizedDisplayName).first
    }
    private static func uniqueDisplayNames(_ values: [String], excluding excludedValue: String) -> [String] {
        let normalizedExcluded = normalizedKey(excludedValue)
        var seen = Set<String>()
        var result: [String] = []
        for value in values {
            guard let trimmed = normalizedDisplayName(value) else { continue }
            let normalized = normalizedKey(trimmed)
            guard normalized != normalizedExcluded,
                  seen.insert(normalized).inserted
            else { continue }
            result.append(trimmed)
        }
        return result
    }
    private static func normalizedDisplayName(_ value: String) -> String? {
        let trimmed = value
            .replacingOccurrences(of: ".app", with: "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }
    private static func normalizedKey(_ value: String) -> String {
        value
            .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
            .lowercased()
            .trimmingCharacters(in: .whitespacesAndNewlines)
    }
}
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
@@ -227,7 +387,7 @@
            }
        }
        return deduplicated(apps).sorted {
        return deduplicated(apps.filter { !isInternalHelperAppPath($0.path) }).sorted {
            $0.name.localizedStandardCompare($1.name) == .orderedAscending
        }
    }
@@ -256,10 +416,10 @@
        guard url.pathExtension.lowercased() == "app" else { return }
        let displayURL = url.standardizedFileURL
        guard !isNestedInsideAppBundle(displayURL) else { return }
        guard !isInternalHelperAppPath(displayURL) else { return }
        let resolvedURL = displayURL.resolvingSymlinksInPath().standardizedFileURL
        guard !isNestedInsideAppBundle(resolvedURL) else { return }
        guard !isInternalHelperAppPath(resolvedURL) else { return }
        guard seenResolvedPaths.insert(resolvedURL.path).inserted else { return }
        let bundle = Bundle(url: displayURL) ?? Bundle(url: resolvedURL)
@@ -282,10 +442,13 @@
                }
            }
        }
        let systemDisplayNames = systemDisplayNames(for: displayURL, fallbackName: name)
        let bundleDisplayNames = bundleDisplayNames(bundle: bundle, fallbackName: name)
        let localizedNames = localizedAppNames(
            for: displayURL,
            fallbackName: name,
            localizedNamesByLanguage: localizedNamesByLanguage
            localizedNamesByLanguage: localizedNamesByLanguage,
            systemDisplayNames: systemDisplayNames,
            bundleDisplayNames: bundleDisplayNames
        )
        apps.append(AppInfo(
@@ -295,6 +458,8 @@
            bundleIdentifier: bundleId,
            localizedNames: localizedNames,
            localizedNamesByLanguage: localizedNamesByLanguage,
            systemDisplayNames: systemDisplayNames,
            bundleDisplayNames: bundleDisplayNames,
            icon: icon
        ))
    }
@@ -325,16 +490,39 @@
    }
    private static func localizedAppNames(
        for appURL: URL,
        fallbackName: String,
        localizedNamesByLanguage: [String: String]
        localizedNamesByLanguage: [String: String],
        systemDisplayNames: [String],
        bundleDisplayNames: [String]
    ) -> [String] {
        var values = L10n.supported.map { localizedNamesByLanguage[$0.code] }
        values.append(resourceLocalizedName(for: appURL))
        values.append(spotlightDisplayName(for: appURL))
        values.append(FileManager.default.displayName(atPath: appURL.path).replacingOccurrences(of: ".app", with: ""))
        values.append(contentsOf: systemDisplayNames.map { Optional($0) })
        values.append(contentsOf: bundleDisplayNames.map { Optional($0) })
        return uniqueLocalizedNames(values, excluding: fallbackName)
    }
    private static func systemDisplayNames(for appURL: URL, fallbackName: String) -> [String] {
        uniqueLocalizedNames(
            [
                resourceLocalizedName(for: appURL),
                spotlightDisplayName(for: appURL),
                FileManager.default.displayName(atPath: appURL.path)
            ],
            excluding: fallbackName
        )
    }
    private static func bundleDisplayNames(bundle: Bundle?, fallbackName: String) -> [String] {
        uniqueLocalizedNames(
            [
                bundle?.localizedInfoDictionary?["CFBundleDisplayName"] as? String,
                bundle?.localizedInfoDictionary?["CFBundleName"] as? String,
                bundle?.infoDictionary?["CFBundleDisplayName"] as? String,
                bundle?.infoDictionary?["CFBundleName"] as? String
            ],
            excluding: fallbackName
        )
    }
    private static func localizedAppName(
@@ -488,12 +676,30 @@
            .trimmingCharacters(in: .whitespacesAndNewlines)
    }
    private static func isNestedInsideAppBundle(_ url: URL) -> Bool {
        let components = url.standardizedFileURL.pathComponents
        guard let lastAppIndex = components.lastIndex(where: { $0.lowercased().hasSuffix(".app") }) else {
    static func isNestedInsideAppBundle(_ url: URL) -> Bool {
        isNestedInsideAppBundlePath(url.standardizedFileURL.path)
    }
    static func isInternalHelperAppPath(_ url: URL) -> Bool {
        let path = url.standardizedFileURL.path
        let lowercasedPath = path.lowercased()
        return isNestedInsideAppBundlePath(path)
            || lowercasedPath.contains("/contents/helpers/")
            || lowercasedPath.contains("/contents/xpcservices/")
            || lowercasedPath.contains("/wrapper/")
    }
    private static func isNestedInsideAppBundlePath(_ path: String) -> Bool {
        let lowercasedPath = path.lowercased()
        guard lowercasedPath.hasSuffix(".app") else { return false }
        let components = lowercasedPath.split(separator: "/", omittingEmptySubsequences: true)
        guard let lastAppIndex = components.lastIndex(where: { $0.hasSuffix(".app") }) else {
            return false
        }
        return components[..<lastAppIndex].contains { $0.lowercased().hasSuffix(".app") }
        if components[..<lastAppIndex].contains(where: { $0.hasSuffix(".app") }) {
            return true
        }
        return lowercasedPath.range(of: ".app/", options: .caseInsensitive) != nil
    }
    private static func deduplicated(_ apps: [AppInfo]) -> [AppInfo] {
@@ -552,27 +758,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] = []
            }
        }
@@ -594,16 +859,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)
        }
    }
@@ -649,6 +970,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
@@ -665,6 +987,7 @@
            case tags
            case appTags
            case tagOrder
            case containerAppOrder
            case uncommonAppPaths
            case uncommonSources
            case appOpenCounts
@@ -685,6 +1008,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) ?? [:]
@@ -705,6 +1032,11 @@
            for path in uncommonAppPaths where uncommonSources[path] == nil {
                uncommonSources[path] = .manual
            }
            containerAppOrder = TagDatabase.normalizedContainerAppOrder(
                containerAppOrder,
                tags: tags,
                appTags: appTags
            )
        }
        var hasUserTagAssignments: Bool {
@@ -758,8 +1090,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
    }
@@ -772,10 +1107,156 @@
    }
    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 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 {
@@ -816,6 +1297,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]
@@ -932,6 +1414,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
@@ -1039,6 +1526,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 })
@@ -1050,6 +1539,8 @@
                store.appTags[path] = renamedTags
            }
        }
        migrateContainerAppOrderKey(in: &store, from: oldContainerID, to: newContainerID)
        _ = normalizeContainerAppOrder(in: &store)
    }
    private static func uniqueOrdered(_ values: [String]) -> [String] {
@@ -1079,6 +1570,20 @@
        flushPendingCategorySchemeBackupBatch()
        save(store)
        return store
    }
    @discardableResult
    static func resetAppTagAssignmentsToUncategorized() -> Store {
        let previousStore = loadWithEnsuredCategoryScheme()
        var store = previousStore
        store.appTags = [:]
        store.containerAppOrder = [:]
        saveUserCategorySchemeMutation(
            store,
            previous: previousStore,
            reason: "reset-uncategorized"
        )
        return loadWithEnsuredCategoryScheme()
    }
    private static func applyImportedCategorySchemeMetadata(to store: inout Store, importedFileURL url: URL) {
@@ -1280,6 +1785,10 @@
    /// 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
@@ -1294,6 +1803,31 @@
            store,
            previous: previousStore,
            reason: "reorder-tags"
        )
    }
    /// Persist the visible app order for one stable container.
    static func reorderApps(inContainer containerID: String, orderedPaths: [String]) {
        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"
        )
    }
@@ -1325,6 +1859,8 @@
                bundleIdentifier: app.bundleIdentifier,
                localizedNames: app.localizedNames,
                localizedNamesByLanguage: app.localizedNamesByLanguage,
                systemDisplayNames: app.systemDisplayNames,
                bundleDisplayNames: app.bundleDisplayNames,
                icon: app.icon,
                isUncommon: uncommonPaths.contains(app.path.path),
                note: store.appNotes[app.path.path]
@@ -1462,8 +1998,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
@@ -1478,9 +2018,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
@@ -1505,6 +2049,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
    }
@@ -1656,7 +2204,14 @@
        let previousStore = store
        // Update tag definition
        if let def = store.tags.removeValue(forKey: oldName) {
            let oldContainerID = AppContainerID.forTag(oldName, definition: def)
            let newContainerID = AppContainerID.forTag(newName, definition: def)
            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
@@ -1666,6 +2221,7 @@
                store.appTags[path] = Array(NSOrderedSet(array: tags).compactMap { $0 as? String })
            }
        }
        _ = TagDatabase.normalizeContainerAppOrder(in: &store)
        TagDatabase.saveUserCategorySchemeMutation(
            store,
            previous: previousStore,
@@ -1677,10 +2233,21 @@
    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 {
@@ -1691,6 +2258,7 @@
                store.appTags[path] = tags
            }
        }
        _ = TagDatabase.normalizeContainerAppOrder(in: &store)
        TagDatabase.saveUserCategorySchemeMutation(
            store,
            previous: previousStore,