From 755ca63e4d37c1a54db7fc6475152319a4e46ceb Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 28 Jun 2026 20:53:56 +0800
Subject: [PATCH] Freeze 8.1.7 colorful theme tuning
---
src/Apptag/DataLayer.swift | 713 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 files changed, 648 insertions(+), 65 deletions(-)
diff --git a/src/Apptag/DataLayer.swift b/src/Apptag/DataLayer.swift
index 17a0605..1357d95 100644
--- a/src/Apptag/DataLayer.swift
+++ b/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] {
@@ -1060,12 +1551,14 @@
// 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)
@@ -1079,6 +1572,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) {
@@ -1248,12 +1755,11 @@
guard !FileManager.default.fileExists(atPath: storeURL.path) else { return }
let starterCategoryIDs: [SmartCategoryID] = [
- .uiPrototyping,
- .ide,
- .writing,
- .game,
+ .design,
+ .development,
+ .office,
.entertainment,
- .system,
+ .systemEnhancement,
.gtd
]
@@ -1280,6 +1786,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 +1804,33 @@
store,
previous: previousStore,
reason: "reorder-tags"
+ )
+ }
+
+ /// 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"
)
}
@@ -1325,6 +1862,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 +2001,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 +2021,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 +2052,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
}
@@ -1589,11 +2140,22 @@
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(
@@ -1619,6 +2181,7 @@
previous: previousStore,
reason: "edit-app-note"
)
+ return decision
}
static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) {
@@ -1656,7 +2219,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 +2236,7 @@
store.appTags[path] = Array(NSOrderedSet(array: tags).compactMap { $0 as? String })
}
}
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
@@ -1677,10 +2248,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 +2273,7 @@
store.appTags[path] = tags
}
}
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
--
Gitblit v1.9.3