From 18a0fe3977175f3f7a945b300524fd2cd37fef28 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 13 Jun 2026 15:15:47 +0800
Subject: [PATCH] Add app grid usage tips
---
src/Apptag/DataLayer.swift | 424 +++++++++++++++++++++++++++++++++++++++++++++++++++-
1 files changed, 412 insertions(+), 12 deletions(-)
diff --git a/src/Apptag/DataLayer.swift b/src/Apptag/DataLayer.swift
index 1cc92f4..d05beda 100644
--- a/src/Apptag/DataLayer.swift
+++ b/src/Apptag/DataLayer.swift
@@ -41,6 +41,44 @@
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)
@@ -177,9 +215,24 @@
}
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
@@ -705,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] = []
}
}
@@ -747,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)
}
}
@@ -802,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
@@ -818,6 +987,7 @@
case tags
case appTags
case tagOrder
+ case containerAppOrder
case uncommonAppPaths
case uncommonSources
case appOpenCounts
@@ -838,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) ?? [:]
@@ -858,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 {
@@ -911,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
}
@@ -925,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 {
@@ -969,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]
@@ -1085,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
@@ -1192,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 })
@@ -1203,6 +1539,8 @@
store.appTags[path] = renamedTags
}
}
+ migrateContainerAppOrderKey(in: &store, from: oldContainerID, to: newContainerID)
+ _ = normalizeContainerAppOrder(in: &store)
}
private static func uniqueOrdered(_ values: [String]) -> [String] {
@@ -1239,6 +1577,7 @@
let previousStore = loadWithEnsuredCategoryScheme()
var store = previousStore
store.appTags = [:]
+ store.containerAppOrder = [:]
saveUserCategorySchemeMutation(
store,
previous: previousStore,
@@ -1446,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
@@ -1460,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"
)
}
@@ -1630,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
@@ -1646,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
@@ -1673,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
}
@@ -1824,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
@@ -1834,6 +2221,7 @@
store.appTags[path] = Array(NSOrderedSet(array: tags).compactMap { $0 as? String })
}
}
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
@@ -1845,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 {
@@ -1859,6 +2258,7 @@
store.appTags[path] = tags
}
}
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
--
Gitblit v1.9.3