Ariver
2026-05-11 c8a1b811bffd13dc6dfa39a7c5c0d4ee76cd11e5
Apptag/DataLayer.swift
@@ -43,7 +43,7 @@
        default: return NSColor.systemGray
        }
    }
    static let allIndices: [Int] = [0, 1, 2, 3, 4, 5, 6, 7]
    static let allIndices: [Int] = [1, 2, 3, 4, 5, 6, 7]
}
// MARK: - App Scanner
@@ -57,8 +57,7 @@
            .appendingPathComponent("Applications")
    ]
    /// Scan all standard locations. Tags are NOT read from Finder —
    /// they're annotated from TagDatabase by the caller.
    /// Scan all standard locations. Tags are annotated from TagDatabase by the caller.
    static func scan() -> [AppInfo] {
        var seen = Set<URL>()
        var apps: [AppInfo] = []
@@ -153,7 +152,6 @@
        var tags: [String: TagDef] = [:]
        var appTags: [String: [String]] = [:]  // path → tag names
        var tagOrder: [String] = []  // display order; empty → alpha sort
        var migrated: Bool = false
    }
    // MARK: Paths
@@ -181,83 +179,6 @@
        try? data.write(to: storeURL, options: .atomic)
    }
    // MARK: Migration (first launch: import Finder tags)
    /// Run once on first launch. Reads Finder tags from all scanned apps
    /// and seeds the local database. Also auto-assigns "Mac自带" to Apple apps.
    static func migrateFromFinderIfNeeded(apps: [AppInfo]) -> Store {
        var store = load()
        guard !store.migrated else { return store }
        // Read Finder tags from each app
        for app in apps {
            let finderTags = readFinderTags(from: app.path)
            guard !finderTags.isEmpty else { continue }
            store.appTags[app.path.path] = finderTags.map { $0.name }
            for (name, color) in finderTags {
                if store.tags[name] == nil {
                    store.tags[name] = TagDef(color: color)
                }
            }
        }
        // Auto-assign "Mac自带" tag to Apple apps
        for app in apps where app.isAppleApp {
            var current = store.appTags[app.path.path] ?? []
            if !current.contains("Mac自带") {
                current.append("Mac自带")
                store.appTags[app.path.path] = current
            }
        }
        if store.tags["Mac自带"] == nil {
            store.tags["Mac自带"] = TagDef(color: 1)  // gray
        }
        store.migrated = true
        save(store)
        return store
    }
    /// Read Finder tags (name, color) from a single .app bundle.
    /// Only used during one-time migration.
    private static func readFinderTags(from url: URL) -> [(name: String, color: Int)] {
        // Read tag names via NSURL resource values
        guard let values = try? url.resourceValues(forKeys: [.tagNamesKey]),
              let tagNames = values.tagNames, !tagNames.isEmpty
        else { return [] }
        // Read tag colors from xattr
        let xattrName = "com.apple.metadata:_kMDItemUserTags"
        let path = url.path
        let size = getxattr(path, xattrName, nil, 0, 0, 0)
        guard size > 0 else {
            return tagNames.map { ($0, 0) }
        }
        var buffer = [UInt8](repeating: 0, count: size)
        guard getxattr(path, xattrName, &buffer, size, 0, 0) == size else {
            return tagNames.map { ($0, 0) }
        }
        let data = Data(buffer)
        guard let plist = try? PropertyListSerialization.propertyList(
            from: data, options: [], format: nil
        ) as? [String] else {
            return tagNames.map { ($0, 0) }
        }
        // Build name→color map from xattr entries
        var colorMap: [String: Int] = [:]
        for entry in plist {
            let parts = entry.components(separatedBy: "\n")
            guard !parts[0].isEmpty else { continue }
            let c = (parts.count >= 2) ? (Int(parts[1]) ?? 0) : 0
            colorMap[parts[0]] = (0...7).contains(c) ? c : 0
        }
        return tagNames.map { ($0, colorMap[$0] ?? 0) }
    }
    // MARK: Export / Import
    static func exportTo(_ url: URL) throws {
@@ -269,6 +190,27 @@
        let store = try JSONDecoder().decode(Store.self, from: data)
        save(store)
        return store
    }
    /// Seed default tags on first launch. Only runs if store doesn't exist yet.
    /// Tag names are loaded from the current language's localization.
    static func seedDefaultTags() {
        guard !FileManager.default.fileExists(atPath: storeURL.path) else { return }
        let keys = [
            "tag.design", "tag.development", "tag.writing",
            "tag.gaming", "tag.entertainment", "tag.system",
            "tag.productivity"
        ]
        let colors: [Int] = [1, 2, 3, 4, 5, 6, 7]
        var store = Store()
        for (i, key) in keys.enumerated() {
            let name = tr(key)
            store.tags[name] = TagDef(color: colors[i])
            store.tagOrder.append(name)
        }
        save(store)
    }
}
@@ -304,7 +246,7 @@
        var store = TagDatabase.load()
        guard store.tags[name] == nil else { return }
        store.tags[name] = TagDatabase.TagDef(color: color)
        if !store.tagOrder.contains(name) { store.tagOrder.append(name) }
        if !store.tagOrder.contains(name) { store.tagOrder.insert(name, at: 0) }
        TagDatabase.save(store)
    }
@@ -329,7 +271,7 @@
        if store.tags[tag] == nil {
            store.tags[tag] = TagDatabase.TagDef(color: color)
            // New tag: append to display order
            if !store.tagOrder.contains(tag) { store.tagOrder.append(tag) }
            if !store.tagOrder.contains(tag) { store.tagOrder.insert(tag, at: 0) }
        }
        for path in paths {
            var current = store.appTags[path] ?? []
@@ -341,18 +283,43 @@
        TagDatabase.save(store)
    }
    /// Remove a tag from specific apps.
    static func removeTag(_ tag: String, from paths: [String]) {
    /// Replace the full editable tag set for multiple apps.
    static func setTags(_ tags: [String], to paths: [String]) {
        var store = TagDatabase.load()
        let validTags = tags.filter { store.tags[$0] != nil }
        for path in paths {
            store.appTags[path]?.removeAll { $0 == tag }
            if store.appTags[path]?.isEmpty == true {
            if validTags.isEmpty {
                store.appTags.removeValue(forKey: path)
            } else {
                store.appTags[path] = validTags
            }
        }
        TagDatabase.save(store)
    }
    static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) {
        var store = TagDatabase.load()
        if store.tags[targetTag] == nil {
            store.tags[targetTag] = TagDatabase.TagDef(color: color)
            if !store.tagOrder.contains(targetTag) { store.tagOrder.insert(targetTag, at: 0) }
        }
        var current = store.appTags[path] ?? []
        if !copy, !sourceTag.isEmpty {
            current.removeAll { $0 == sourceTag }
        }
        if !current.contains(targetTag) {
            current.append(targetTag)
        }
        if current.isEmpty {
            store.appTags.removeValue(forKey: path)
        } else {
            store.appTags[path] = current
        }
        TagDatabase.save(store)
    }
    /// Rename a tag on all apps that have it.
    static func renameTag(from oldName: String, to newName: String) {
        var store = TagDatabase.load()