import Foundation
// MARK: - Smart Start Catalog Runtime
struct SmartStartSummary: Equatable {
let matchedAppCount: Int
let assignedTagCount: Int
let createdTagCount: Int
let backupPath: String?
}
enum SmartStartRunMode: Equatable {
case none
case autoApplied
case suggestionOnly
}
struct SmartStartRunResult {
let mode: SmartStartRunMode
let store: TagDatabase.Store
let draft: SmartCategorizationDraft?
let summary: SmartStartSummary?
}
private struct SmartStartCatalogEntry {
let rank: Int
let name: String
let normalizedName: String
let bundleIdentifier: String?
let categoryIDs: [SmartCategoryID]
let localizedNote: String?
let notes: [String: String]?
let sourceEvidence: [String]
}
private struct SmartStartRuntimeCatalog: Decodable {
let version: Int
let entries: [SmartStartRuntimeCatalogEntry]
}
private struct SmartStartRuntimeCatalogEntry: Decodable {
let rank: Int?
let name: String
let normalizedName: String?
let bundleIdentifier: String?
let defaultTag: [String]
let notes: [String: String]?
let sourceEvidence: [String]?
}
enum SmartStartService {
static let catalogVersion = 2
static let systemInitialSchemeCreatedAt: Date = {
var components = DateComponents()
components.calendar = Calendar(identifier: .gregorian)
components.timeZone = TimeZone(identifier: "Asia/Hong_Kong")
components.year = 2026
components.month = 5
components.day = 18
components.hour = 0
components.minute = 0
components.second = 0
return components.date ?? Date(timeIntervalSince1970: 0)
}()
static var systemInitialSchemeName: String {
TagDatabase.schemeName(prefixKey: "scheme.systemSmartStart", at: systemInitialSchemeCreatedAt)
}
static func runIfNeeded(
apps: [AppInfo],
store initialStore: TagDatabase.Store
) -> SmartStartRunResult {
guard shouldRun(for: initialStore) else {
return SmartStartRunResult(mode: .none, store: initialStore, draft: nil, summary: nil)
}
let draft = makeDraft(apps: apps)
guard !draft.assignments.isEmpty else {
var store = initialStore
store.smartStart.catalogVersion = catalogVersion
store.smartStart.lastRunAt = Date()
store.smartStart.lastMode = "no_matches"
TagDatabase.save(store)
return SmartStartRunResult(mode: .none, store: store, draft: nil, summary: nil)
}
if shouldAutoApply(to: initialStore) {
let applied = applyDraft(draft, to: initialStore, mode: "auto_applied")
return SmartStartRunResult(
mode: .autoApplied,
store: applied.store,
draft: draft,
summary: applied.summary
)
}
var store = initialStore
store.smartStart.catalogVersion = catalogVersion
store.smartStart.lastRunAt = Date()
store.smartStart.lastSuggestionAt = Date()
store.smartStart.lastMode = "suggestion_only"
store.smartStart.lastMatchedAppCount = draft.assignments.count
store.smartStart.lastAssignedTagCount = draft.assignments.reduce(0) { $0 + $1.categoryIDs.count }
TagDatabase.save(store)
let summary = SmartStartSummary(
matchedAppCount: draft.assignments.count,
assignedTagCount: draft.assignments.reduce(0) { $0 + $1.categoryIDs.count },
createdTagCount: 0,
backupPath: nil
)
return SmartStartRunResult(mode: .suggestionOnly, store: store, draft: draft, summary: summary)
}
static func applySuggestion(_ draft: SmartCategorizationDraft) -> SmartStartRunResult {
let applied = applyDraft(draft, to: TagDatabase.load(), mode: "manual_applied")
return SmartStartRunResult(
mode: .autoApplied,
store: applied.store,
draft: draft,
summary: applied.summary
)
}
static func applySystemInitialScheme(apps: [AppInfo]) -> SmartStartRunResult {
let draft = makeDraft(apps: apps)
guard !draft.assignments.isEmpty else {
return SmartStartRunResult(mode: .none, store: TagDatabase.load(), draft: draft, summary: nil)
}
let applied = applyDraft(
draft,
to: TagDatabase.loadWithEnsuredCategoryScheme(),
mode: "settings_system_applied",
replaceExistingScheme: true
)
return SmartStartRunResult(
mode: .autoApplied,
store: applied.store,
draft: draft,
summary: applied.summary
)
}
@discardableResult
static func relocalizeDefaultNotesForCurrentLanguage(apps: [AppInfo]) -> Bool {
let catalog = loadCatalog()
guard !catalog.isEmpty else { return false }
var bundleIndex: [String: SmartStartCatalogEntry] = [:]
for entry in catalog {
guard let bundleIdentifier = entry.bundleIdentifier?.lowercased(), !bundleIdentifier.isEmpty else {
continue
}
if let existing = bundleIndex[bundleIdentifier], existing.rank <= entry.rank {
continue
}
bundleIndex[bundleIdentifier] = entry
}
let nameIndex = Dictionary(
grouping: catalog,
by: { $0.normalizedName }
).compactMapValues { entries in
entries.sorted { $0.rank < $1.rank }.first
}
var store = TagDatabase.load()
var changed = false
for app in apps {
let path = app.path.path
guard let currentNote = normalizedNote(store.appNotes[path]) else { continue }
guard let matched = match(app: app, bundleIndex: bundleIndex, nameIndex: nameIndex),
let notes = matched.notes,
!notes.isEmpty
else { continue }
let knownDefaultNotes = Set(notes.values.compactMap(normalizedNote))
guard knownDefaultNotes.contains(currentNote),
let localizedNote = localizedNote(from: notes),
localizedNote != currentNote
else { continue }
store.appNotes[path] = localizedNote
changed = true
}
if changed {
TagDatabase.save(store)
}
return changed
}
static func restoreBackup(at path: String) -> Bool {
let url = URL(fileURLWithPath: path)
guard let data = try? Data(contentsOf: url),
var store = try? JSONDecoder().decode(TagDatabase.Store.self, from: data)
else { return false }
let now = Date()
if store.categoryScheme.currentName == nil {
store.categoryScheme.currentName = TagDatabase.schemeName(prefixKey: "scheme.beforeSmartStart", at: now)
store.categoryScheme.currentCreatedAt = now
}
store.categoryScheme.lastChangedAt = now
store.smartStart.catalogVersion = catalogVersion
store.smartStart.lastRunAt = now
store.smartStart.lastMode = "restored_from_backup"
store.smartStart.lastBackupPath = path
TagDatabase.save(store)
return true
}
static func makeDraft(apps: [AppInfo]) -> SmartCategorizationDraft {
let catalog = loadCatalog()
var bundleIndex: [String: SmartStartCatalogEntry] = [:]
for entry in catalog {
guard let bundleIdentifier = entry.bundleIdentifier?.lowercased(), !bundleIdentifier.isEmpty else {
continue
}
if let existing = bundleIndex[bundleIdentifier], existing.rank <= entry.rank {
continue
}
bundleIndex[bundleIdentifier] = entry
}
let nameIndex = Dictionary(
grouping: catalog,
by: { $0.normalizedName }
).compactMapValues { entries in
entries.sorted { $0.rank < $1.rank }.first
}
var seenAssignments = Set()
var assignments: [SmartAppCategorizationAssignment] = []
var unassigned: [SmartUnassignedApp] = []
for app in apps {
let matched = match(app: app, bundleIndex: bundleIndex, nameIndex: nameIndex)
guard let matched else {
unassigned.append(SmartUnassignedApp(
appName: app.name,
bundleIdentifier: app.bundleIdentifier,
path: app.path.path,
reason: "No local Smart Start catalog match"
))
continue
}
let key = app.bundleIdentifier?.lowercased() ?? app.path.path
guard seenAssignments.insert(key).inserted else { continue }
assignments.append(SmartAppCategorizationAssignment(
appName: app.name,
bundleIdentifier: app.bundleIdentifier,
path: app.path.path,
categoryIDs: matched.categoryIDs,
confidence: confidence(for: matched),
source: .localCatalog,
reason: "Matched Smart Start catalog entry: \(matched.name)",
provenance: matched.sourceEvidence,
defaultNote: matched.localizedNote,
defaultNoteCandidates: matched.notes?.values.map { $0 } ?? []
))
}
return SmartCategorizationDraft(
draftSource: .localSmartStart,
assignments: assignments.sorted {
$0.appName.localizedStandardCompare($1.appName) == .orderedAscending
},
unassigned: unassigned
)
}
private static func shouldRun(for store: TagDatabase.Store) -> Bool {
store.smartStart.catalogVersion < catalogVersion
|| (store.smartStart.lastRunAt == nil
&& store.smartStart.lastAppliedAt == nil
&& store.smartStart.lastSuggestionAt == nil)
}
private static func shouldAutoApply(to store: TagDatabase.Store) -> Bool {
!store.hasUserTagAssignments
}
private static func applyDraft(
_ draft: SmartCategorizationDraft,
to initialStore: TagDatabase.Store,
mode: String,
replaceExistingScheme: Bool = false
) -> (store: TagDatabase.Store, summary: SmartStartSummary) {
var normalizedInitialStore = initialStore
_ = TagDatabase.normalizeCategorySchemeMetadata(&normalizedInitialStore)
var store = normalizedInitialStore
let now = Date()
let backupURL = TagDatabase.backup(normalizedInitialStore, reason: "smart-start-\(mode)")
let usedCategoryIDs = orderedUsedCategoryIDs(in: draft)
let systemCategoryIDsToCreate = replaceExistingScheme
? SmartCategoryDefaults.orderedIDs
: usedCategoryIDs
var createdTagCount = 0
if replaceExistingScheme {
store.tags = [:]
store.appTags = [:]
store.tagOrder = []
store.disabledSystemCategoryIDs = []
}
for categoryID in systemCategoryIDsToCreate {
guard replaceExistingScheme || !store.disabledSystemCategoryIDs.contains(categoryID) else {
continue
}
if let result = TagDatabase.ensureSystemTag(for: categoryID, in: &store) {
if var tagDef = store.tags[result.name] {
tagDef.color = categoryID.defaultColorIndex
store.tags[result.name] = tagDef
}
if result.created {
createdTagCount += 1
}
if !store.tagOrder.contains(result.name) {
store.tagOrder.append(result.name)
}
}
}
var assignedTagCount = 0
var uncommonPaths = Set(store.uncommonAppPaths)
for assignment in draft.assignments {
guard let path = assignment.path, !path.isEmpty else { continue }
var currentTags = store.appTags[path] ?? []
for categoryID in assignment.categoryIDs {
guard replaceExistingScheme || !store.disabledSystemCategoryIDs.contains(categoryID) else {
continue
}
guard let tagName = TagDatabase.systemTagName(for: categoryID, in: store) else {
continue
}
guard store.tags[tagName] != nil else { continue }
if !currentTags.contains(tagName) {
currentTags.append(tagName)
assignedTagCount += 1
}
}
if currentTags.isEmpty {
store.appTags.removeValue(forKey: path)
} else {
store.appTags[path] = currentTags
}
if let defaultNote = assignment.defaultNote?.trimmingCharacters(in: .whitespacesAndNewlines),
!defaultNote.isEmpty {
let currentNote = store.appNotes[path]?.trimmingCharacters(in: .whitespacesAndNewlines)
let knownDefaultNotes = Set(assignment.defaultNoteCandidates.compactMap(normalizedNote))
let normalizedCurrentNote = normalizedNote(currentNote)
let shouldSeedNote = currentNote?.isEmpty != false
|| normalizedCurrentNote.map { knownDefaultNotes.contains($0) } == true
if shouldSeedNote {
store.appNotes[path] = String(defaultNote.prefix(TagDatabase.maxAppNoteLength))
if currentNote?.isEmpty != false {
uncommonPaths.insert(path)
if store.uncommonSources[path] == nil {
store.uncommonSources[path] = .auto
}
}
}
}
}
store.uncommonAppPaths = uncommonPaths.sorted()
store.smartStart.catalogVersion = catalogVersion
store.smartStart.lastRunAt = now
store.smartStart.lastAppliedAt = now
store.smartStart.lastBackupPath = backupURL?.path
store.smartStart.lastMode = mode
store.smartStart.lastMatchedAppCount = draft.assignments.count
store.smartStart.lastAssignedTagCount = assignedTagCount
store.categoryScheme.previousName = normalizedInitialStore.categoryScheme.currentName
?? TagDatabase.schemeName(prefixKey: "scheme.beforeSmartStart", at: now)
store.categoryScheme.previousCreatedAt = normalizedInitialStore.categoryScheme.currentCreatedAt
store.categoryScheme.previousBackupPath = backupURL?.path
store.categoryScheme.currentName = replaceExistingScheme
? Self.systemInitialSchemeName
: TagDatabase.schemeName(prefixKey: "scheme.smartStart", at: Self.systemInitialSchemeCreatedAt)
store.categoryScheme.currentCreatedAt = replaceExistingScheme
? Self.systemInitialSchemeCreatedAt
: Self.systemInitialSchemeCreatedAt
store.categoryScheme.lastChangedAt = now
TagDatabase.save(store)
return (
store,
SmartStartSummary(
matchedAppCount: draft.assignments.count,
assignedTagCount: assignedTagCount,
createdTagCount: createdTagCount,
backupPath: backupURL?.path
)
)
}
private static func orderedUsedCategoryIDs(in draft: SmartCategorizationDraft) -> [SmartCategoryID] {
let used = Set(draft.assignments.flatMap(\.categoryIDs))
return SmartCategoryDefaults.orderedIDs.filter { used.contains($0) }
}
private static func match(
app: AppInfo,
bundleIndex: [String: SmartStartCatalogEntry],
nameIndex: [String: SmartStartCatalogEntry]
) -> SmartStartCatalogEntry? {
if let bundleIdentifier = app.bundleIdentifier?.lowercased(),
let bundleMatch = bundleIndex[bundleIdentifier] {
return bundleMatch
}
return nameIndex[normalizedName(app.name)]
}
private static func confidence(for entry: SmartStartCatalogEntry) -> Double {
if entry.rank <= 300 { return 0.86 }
if entry.categoryIDs.count > 1 { return 0.74 }
return 0.68
}
private static func loadCatalog() -> [SmartStartCatalogEntry] {
if let catalog = loadRuntimeCatalog() {
return catalog
}
return loadLegacyCSVCatalog()
}
private static func loadRuntimeCatalog() -> [SmartStartCatalogEntry]? {
guard let url = Bundle.main.url(forResource: "SmartStartUltimateDefaultCatalog", withExtension: "json"),
let data = try? Data(contentsOf: url),
let catalog = try? JSONDecoder().decode(SmartStartRuntimeCatalog.self, from: data)
else { return nil }
return catalog.entries.compactMap { entry in
let tagIDs = entry.defaultTag
.compactMap { SmartCategoryID(rawValue: $0) }
.filter { $0 != .other }
guard !tagIDs.isEmpty else { return nil }
return SmartStartCatalogEntry(
rank: entry.rank ?? Int.max,
name: entry.name,
normalizedName: entry.normalizedName.flatMap {
$0.isEmpty ? nil : $0
} ?? normalizedName(entry.name),
bundleIdentifier: normalizedBundleIdentifier(entry.bundleIdentifier),
categoryIDs: uniqueOrdered(tagIDs),
localizedNote: localizedNote(from: entry.notes),
notes: entry.notes,
sourceEvidence: entry.sourceEvidence ?? []
)
}
}
private static func loadLegacyCSVCatalog() -> [SmartStartCatalogEntry] {
guard let url = Bundle.main.url(forResource: "SmartStartAppDefaultTags", withExtension: "csv"),
let csv = try? String(contentsOf: url, encoding: .utf8)
else { return [] }
let rows = parseCSV(csv)
guard let header = rows.first else { return [] }
let dataRows = rows.dropFirst()
return dataRows.compactMap { row in
let values = Dictionary(uniqueKeysWithValues: header.enumerated().map { index, key in
(key, index < row.count ? row[index] : "")
})
let tagIDs = splitPipe(values["defaultTagIDs"] ?? "")
.compactMap { SmartCategoryID(rawValue: $0) }
.filter { $0 != .other }
guard !tagIDs.isEmpty else { return nil }
return SmartStartCatalogEntry(
rank: Int(values["rank"] ?? "") ?? Int.max,
name: values["name"] ?? "",
normalizedName: values["normalizedName"].flatMap {
$0.isEmpty ? nil : $0
} ?? normalizedName(values["name"] ?? ""),
bundleIdentifier: normalizedBundleIdentifier(values["bundleIdentifier"]),
categoryIDs: uniqueOrdered(tagIDs),
localizedNote: nil,
notes: nil,
sourceEvidence: splitPipe(values["sourceEvidence"] ?? "")
)
}
}
private static func localizedNote(from notes: [String: String]?, preferredCode: String = L10n.currentCode) -> String? {
guard let notes, !notes.isEmpty else { return nil }
let preferredCodes = [
preferredCode,
"en",
"zh-Hans",
"zh-Hant"
]
for code in preferredCodes {
if let note = normalizedNote(notes[code]) {
return note
}
}
return notes.values
.compactMap(normalizedNote)
.first
}
private static func normalizedNote(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return String(trimmed.prefix(TagDatabase.maxAppNoteLength))
}
private static func normalizedBundleIdentifier(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty,
!["null", "nil", "undefined"].contains(value.lowercased())
else { return nil }
return value
}
private static func uniqueOrdered(_ ids: [SmartCategoryID]) -> [SmartCategoryID] {
var seen = Set()
return ids.filter { seen.insert($0).inserted }
}
private static func splitPipe(_ value: String) -> [String] {
value
.split(separator: "|")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private static func normalizedName(_ value: String) -> String {
value
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
.replacingOccurrences(of: "&", with: " and ")
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
.joined(separator: "-")
}
private static func parseCSV(_ csv: String) -> [[String]] {
var rows: [[String]] = []
var row: [String] = []
var field = ""
var quoted = false
var index = csv.startIndex
while index < csv.endIndex {
let character = csv[index]
if quoted {
if character == "\"" {
let next = csv.index(after: index)
if next < csv.endIndex, csv[next] == "\"" {
field.append("\"")
index = next
} else {
quoted = false
}
} else {
field.append(character)
}
} else {
switch character {
case "\"":
quoted = true
case ",":
row.append(field)
field = ""
case "\n":
row.append(field)
rows.append(row)
row = []
field = ""
case "\r":
break
default:
field.append(character)
}
}
index = csv.index(after: index)
}
if !field.isEmpty || !row.isEmpty {
row.append(field)
rows.append(row)
}
return rows
}
}