import Foundation
|
import StoreKit
|
import SwiftUI
|
|
enum ProFeature: String, CaseIterable {
|
case premiumThemes
|
case layoutImport
|
case layoutExport
|
case customTagColors
|
case customContainerQuota
|
case unlimitedNotes
|
case persistentAppSorting
|
case customHotkeys
|
|
var titleKey: String {
|
switch self {
|
case .premiumThemes:
|
return "pro.feature.themes"
|
case .layoutImport:
|
return "pro.feature.import"
|
case .layoutExport:
|
return "pro.feature.export"
|
case .customTagColors:
|
return "pro.feature.customTagColors"
|
case .customContainerQuota:
|
return "pro.feature.customContainerQuota"
|
case .unlimitedNotes:
|
return "pro.feature.notes"
|
case .persistentAppSorting:
|
return "pro.feature.sorting"
|
case .customHotkeys:
|
return "pro.feature.customHotkeys"
|
}
|
}
|
|
var benefitKey: String {
|
switch self {
|
case .premiumThemes:
|
return "pro.feature.themes.benefit"
|
case .layoutImport:
|
return "pro.feature.import.benefit"
|
case .layoutExport:
|
return "pro.feature.export.benefit"
|
case .customTagColors:
|
return "pro.feature.customTagColors.benefit"
|
case .customContainerQuota:
|
return "pro.feature.customContainerQuota.benefit"
|
case .unlimitedNotes:
|
return "pro.feature.notes.benefit"
|
case .persistentAppSorting:
|
return "pro.feature.sorting.benefit"
|
case .customHotkeys:
|
return "pro.feature.customHotkeys.benefit"
|
}
|
}
|
}
|
|
enum ProAccessState: String, Codable, Equatable {
|
case free
|
case purchased
|
case legacy
|
|
var isUnlocked: Bool {
|
self != .free
|
}
|
}
|
|
enum ProOperationState: Equatable {
|
case idle
|
case loading
|
case purchasing
|
case restoring
|
case pending
|
case cancelled
|
case restoreNotFound
|
case failed(messageKey: String)
|
|
var isBusy: Bool {
|
switch self {
|
case .loading, .purchasing, .restoring:
|
return true
|
case .idle, .pending, .cancelled, .restoreNotFound, .failed:
|
return false
|
}
|
}
|
}
|
|
enum ProEntitlementError: Error, Equatable {
|
case featureLocked(ProFeature)
|
case noteQuotaExceeded(ProNoteQuotaStatus)
|
|
var feature: ProFeature? {
|
switch self {
|
case .featureLocked(let feature):
|
return feature
|
case .noteQuotaExceeded:
|
return .unlimitedNotes
|
}
|
}
|
|
var noteQuotaStatus: ProNoteQuotaStatus? {
|
switch self {
|
case .featureLocked:
|
return nil
|
case .noteQuotaExceeded(let status):
|
return status
|
}
|
}
|
}
|
|
struct ProThemePreviewState: Equatable {
|
let theme: AppGridTheme
|
let themeTuning: Double?
|
let startedAt: Date
|
let endsAt: Date
|
|
func remainingSeconds(at date: Date = Date()) -> Int {
|
max(0, Int(ceil(endsAt.timeIntervalSince(date))))
|
}
|
}
|
|
struct ProDisplayModePreviewState: Equatable {
|
let displayMode: String
|
let startedAt: Date
|
let endsAt: Date
|
|
func remainingSeconds(at date: Date = Date()) -> Int {
|
max(0, Int(ceil(endsAt.timeIntervalSince(date))))
|
}
|
}
|
|
struct ProNoteQuotaStatus: Equatable {
|
let isUnlimited: Bool
|
let used: Int
|
let limit: Int
|
|
var remaining: Int {
|
max(0, limit - used)
|
}
|
}
|
|
enum ProNoteSaveDecision: Equatable {
|
case allow(ProNoteQuotaStatus)
|
case blocked(ProNoteQuotaStatus)
|
}
|
|
struct ProCustomContainerQuotaStatus: Equatable {
|
let isUnlimited: Bool
|
let used: Int
|
let limit: Int
|
|
var remaining: Int {
|
max(0, limit - used)
|
}
|
}
|
|
enum ProCustomContainerMaintenanceDecision: Equatable {
|
case allow(ProCustomContainerQuotaStatus)
|
case blocked(ProCustomContainerQuotaStatus)
|
}
|
|
enum ProEntitlementConfig {
|
// 发布前只改这里,不允许把商品信息和边界版本散落到业务模块。
|
static let lifetimeProductID = "com.taglauncher.pro.lifetime"
|
static let firstFreeProVersion = "8.3.2"
|
static let legacyFallbackOriginalPurchaseDateISO8601: String? = nil
|
|
static let freeThemes: Set<AppGridTheme> = [.defaultLight, .black]
|
static let freeNoteLimit = 3
|
static let freeCustomContainerLimit = 6
|
static let defaultThemePreviewDuration: TimeInterval = 5 * 60
|
|
static let qaStateEnvKey = "TAGLAUNCHER_QA_PRO_STATE"
|
static let qaThemePreviewSecondsEnvKey = "TAGLAUNCHER_QA_THEME_PREVIEW_SECONDS"
|
|
static var legacyFallbackOriginalPurchaseDate: Date? {
|
guard let value = legacyFallbackOriginalPurchaseDateISO8601 else { return nil }
|
return iso8601Formatter.date(from: value)
|
}
|
|
static var themePreviewDuration: TimeInterval {
|
let environment = ProcessInfo.processInfo.environment
|
if let raw = environment[qaThemePreviewSecondsEnvKey],
|
let seconds = TimeInterval(raw),
|
seconds > 0 {
|
return seconds
|
}
|
return defaultThemePreviewDuration
|
}
|
|
private static let iso8601Formatter: ISO8601DateFormatter = {
|
let formatter = ISO8601DateFormatter()
|
formatter.formatOptions = [.withInternetDateTime]
|
return formatter
|
}()
|
}
|
|
private struct ProEntitlementCache: Codable {
|
var accessState: ProAccessState
|
var updatedAt: Date
|
var originalAppVersion: String?
|
var originalPurchaseDate: Date?
|
}
|
|
private struct ProEntitlementSnapshot {
|
var accessState: ProAccessState = .free
|
var previewThemeID: String? = nil
|
var previewThemeTuning: Double? = nil
|
var previewDisplayMode: String? = nil
|
}
|
|
private enum ProEntitlementSnapshotStore {
|
private static let lock = NSLock()
|
private static var snapshot = ProEntitlementSnapshot()
|
|
static func update(_ block: (inout ProEntitlementSnapshot) -> Void) {
|
lock.lock()
|
block(&snapshot)
|
lock.unlock()
|
}
|
|
static func read() -> ProEntitlementSnapshot {
|
lock.lock()
|
let value = snapshot
|
lock.unlock()
|
return value
|
}
|
}
|
|
enum ProEntitlementPolicy {
|
static let premiumDisplayModes: Set<String> = ["coloredContainer", "coloredGridContainer"]
|
|
static func accessState() -> ProAccessState {
|
ProEntitlementSnapshotStore.read().accessState
|
}
|
|
static func lockedErrorIfNeeded(for feature: ProFeature) -> ProEntitlementError? {
|
isUnlocked(feature) ? nil : .featureLocked(feature)
|
}
|
|
static func requireUnlocked(_ feature: ProFeature) throws {
|
if let error = lockedErrorIfNeeded(for: feature) {
|
throw error
|
}
|
}
|
|
static func isUnlocked(_ feature: ProFeature) -> Bool {
|
switch feature {
|
case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .customContainerQuota, .unlimitedNotes, .persistentAppSorting, .customHotkeys:
|
return accessState().isUnlocked
|
}
|
}
|
|
static func canUseTheme(_ theme: AppGridTheme) -> Bool {
|
ProEntitlementConfig.freeThemes.contains(theme) || isUnlocked(.premiumThemes)
|
}
|
|
static func isPremiumDisplayMode(_ displayMode: String) -> Bool {
|
premiumDisplayModes.contains(displayMode)
|
}
|
|
static func canUseDisplayMode(_ displayMode: String) -> Bool {
|
!isPremiumDisplayMode(displayMode) || isUnlocked(.premiumThemes)
|
}
|
|
static func fallbackDisplayMode(for displayMode: String) -> String {
|
switch displayMode {
|
case "coloredContainer":
|
return "container"
|
case "coloredGridContainer":
|
return "gridContainer"
|
default:
|
return displayMode
|
}
|
}
|
|
static func effectiveDisplayMode(for storedDisplayMode: String) -> String {
|
let snapshot = ProEntitlementSnapshotStore.read()
|
if let previewDisplayMode = snapshot.previewDisplayMode,
|
isPremiumDisplayMode(previewDisplayMode) {
|
return previewDisplayMode
|
}
|
guard canUseDisplayMode(storedDisplayMode) else {
|
return fallbackDisplayMode(for: storedDisplayMode)
|
}
|
return storedDisplayMode
|
}
|
|
static func effectiveTheme(for storedTheme: AppGridTheme) -> AppGridTheme {
|
if let previewID = ProEntitlementSnapshotStore.read().previewThemeID,
|
let previewTheme = AppGridTheme(rawValue: previewID) {
|
return previewTheme
|
}
|
guard canUseTheme(storedTheme) else {
|
return .defaultLight
|
}
|
return storedTheme
|
}
|
|
static func effectiveThemeTuning(for storedTheme: AppGridTheme, storedTuning: Double?) -> Double? {
|
let snapshot = ProEntitlementSnapshotStore.read()
|
if let previewID = snapshot.previewThemeID,
|
let previewTheme = AppGridTheme(rawValue: previewID),
|
previewTheme.supportsColorTuning {
|
return previewTheme.normalizedTuning(snapshot.previewThemeTuning)
|
}
|
guard canUseTheme(storedTheme), storedTheme.supportsColorTuning else {
|
return nil
|
}
|
return storedTheme.normalizedTuning(storedTuning)
|
}
|
|
static func noteQuotaStatus(in store: TagDatabase.Store? = nil) -> ProNoteQuotaStatus {
|
guard !isUnlocked(.unlimitedNotes) else {
|
return ProNoteQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
|
}
|
let used = TagDatabase.manualNonEmptyNoteCount(in: store)
|
return ProNoteQuotaStatus(
|
isUnlimited: false,
|
used: used,
|
limit: ProEntitlementConfig.freeNoteLimit
|
)
|
}
|
|
static func noteSaveDecision(note: String, for path: String, in store: TagDatabase.Store? = nil) -> ProNoteSaveDecision {
|
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
let effectiveStore = store ?? TagDatabase.load()
|
let currentQuota = noteQuotaStatus(in: effectiveStore)
|
guard !currentQuota.isUnlimited else { return .allow(currentQuota) }
|
|
let manualNotePaths = TagDatabase.manualNonEmptyNotePaths(in: effectiveStore)
|
let hasExistingManualNote = manualNotePaths.contains(path)
|
let willPersistManualNote = !trimmed.isEmpty
|
let limit = ProEntitlementConfig.freeNoteLimit
|
let resultingUsed: Int
|
|
switch (hasExistingManualNote, willPersistManualNote) {
|
case (true, true):
|
resultingUsed = manualNotePaths.count
|
case (true, false):
|
resultingUsed = max(0, manualNotePaths.count - 1)
|
case (false, true):
|
guard manualNotePaths.count < limit else {
|
return .blocked(currentQuota)
|
}
|
resultingUsed = manualNotePaths.count + 1
|
case (false, false):
|
resultingUsed = manualNotePaths.count
|
}
|
|
return .allow(
|
ProNoteQuotaStatus(
|
isUnlimited: false,
|
used: resultingUsed,
|
limit: limit
|
)
|
)
|
}
|
|
static func requireNoteSave(note: String, for path: String, in store: TagDatabase.Store? = nil) throws -> ProNoteQuotaStatus {
|
switch noteSaveDecision(note: note, for: path, in: store) {
|
case .allow(let status):
|
return status
|
case .blocked(let status):
|
throw ProEntitlementError.noteQuotaExceeded(status)
|
}
|
}
|
|
static func customContainerQuotaStatus(in store: TagDatabase.Store? = nil) -> ProCustomContainerQuotaStatus {
|
guard !isUnlocked(.customContainerQuota) else {
|
return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
|
}
|
let used = TagDatabase.customMaintainedContainerCount(in: store)
|
return ProCustomContainerQuotaStatus(
|
isUnlimited: false,
|
used: used,
|
limit: ProEntitlementConfig.freeCustomContainerLimit
|
)
|
}
|
|
static func customContainerMaintenanceDecision(
|
addingCustomContainerCount addedCount: Int,
|
in store: TagDatabase.Store? = nil
|
) -> ProCustomContainerMaintenanceDecision {
|
let status = customContainerQuotaStatus(in: store)
|
guard !status.isUnlimited else { return .allow(status) }
|
guard addedCount > 0 else { return .allow(status) }
|
guard status.used + addedCount <= status.limit else {
|
return .blocked(status)
|
}
|
return .allow(
|
ProCustomContainerQuotaStatus(
|
isUnlimited: false,
|
used: status.used + addedCount,
|
limit: status.limit
|
)
|
)
|
}
|
}
|
|
@MainActor
|
final class ProEntitlementCenter: ObservableObject {
|
static let shared = ProEntitlementCenter()
|
|
@Published private(set) var accessState: ProAccessState = .free
|
@Published private(set) var operationState: ProOperationState = .idle
|
@Published private(set) var priceDisplayText: String? = nil
|
@Published private(set) var themePreviewState: ProThemePreviewState? = nil
|
@Published private(set) var displayModePreviewState: ProDisplayModePreviewState? = nil
|
|
private var hasStarted = false
|
private var cachedProduct: Product?
|
private var updateTask: Task<Void, Never>?
|
private var themePreviewTask: Task<Void, Never>?
|
private var displayModePreviewTask: Task<Void, Never>?
|
|
private init() {}
|
|
deinit {
|
updateTask?.cancel()
|
themePreviewTask?.cancel()
|
displayModePreviewTask?.cancel()
|
}
|
|
var isUnlocked: Bool {
|
accessState.isUnlocked
|
}
|
|
var isPreviewingTheme: Bool {
|
themePreviewState != nil
|
}
|
|
var isPreviewingProAppearance: Bool {
|
themePreviewState != nil || displayModePreviewState != nil
|
}
|
|
var compactStatusTextKey: String {
|
if isPreviewingProAppearance {
|
return "pro.theme.preview.status"
|
}
|
switch accessState {
|
case .free:
|
return "pro.status.free"
|
case .purchased:
|
return "pro.status.unlocked"
|
case .legacy:
|
return "pro.status.legacyUnlocked"
|
}
|
}
|
|
func start() {
|
guard !hasStarted else { return }
|
hasStarted = true
|
|
if let override = qaOverrideAccessState {
|
applyAccessState(override, persistCache: false)
|
operationState = .idle
|
return
|
}
|
|
if let cached = loadCache() {
|
applyAccessState(cached.accessState, persistCache: false)
|
}
|
|
operationState = .loading
|
startTransactionUpdates()
|
|
Task {
|
_ = try? await loadProductIfNeeded()
|
await refreshEntitlements()
|
}
|
}
|
|
func purchasePro() {
|
Task {
|
operationState = .purchasing
|
do {
|
guard let product = try await loadProductIfNeeded() else {
|
operationState = .failed(messageKey: "pro.purchase.unavailable")
|
return
|
}
|
let result = try await product.purchase()
|
switch result {
|
case .success(let verification):
|
switch verification {
|
case .verified(let transaction):
|
await transaction.finish()
|
await refreshEntitlements()
|
operationState = .idle
|
case .unverified:
|
operationState = .failed(messageKey: "pro.purchase.verificationFailed")
|
}
|
case .pending:
|
operationState = .pending
|
case .userCancelled:
|
operationState = .cancelled
|
@unknown default:
|
operationState = .failed(messageKey: "pro.purchase.failed")
|
}
|
} catch {
|
operationState = .failed(messageKey: "pro.purchase.failed")
|
}
|
}
|
}
|
|
func restorePurchases() {
|
Task {
|
operationState = .restoring
|
do {
|
try await AppStore.sync()
|
await refreshEntitlements()
|
if accessState.isUnlocked {
|
operationState = .idle
|
} else {
|
operationState = .restoreNotFound
|
}
|
} catch {
|
operationState = .failed(messageKey: "pro.restore.failed")
|
}
|
}
|
}
|
|
func clearTransientOperationState() {
|
switch operationState {
|
case .pending, .cancelled, .restoreNotFound, .failed:
|
operationState = .idle
|
case .idle, .loading, .purchasing, .restoring:
|
break
|
}
|
}
|
|
func startThemePreview(for theme: AppGridTheme, tuning: Double? = nil) {
|
guard !ProEntitlementConfig.freeThemes.contains(theme) else { return }
|
guard !accessState.isUnlocked else { return }
|
|
stopDisplayModePreview()
|
themePreviewTask?.cancel()
|
|
let preview = ProThemePreviewState(
|
theme: theme,
|
themeTuning: theme.supportsColorTuning ? theme.normalizedTuning(tuning) : nil,
|
startedAt: Date(),
|
endsAt: Date().addingTimeInterval(ProEntitlementConfig.themePreviewDuration)
|
)
|
applyThemePreview(preview)
|
|
themePreviewTask = Task { [weak self] in
|
let duration = max(1, ProEntitlementConfig.themePreviewDuration)
|
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
|
guard !Task.isCancelled else { return }
|
self?.stopThemePreview()
|
}
|
}
|
|
func updateThemePreviewTuning(_ tuning: Double, for theme: AppGridTheme) {
|
guard let preview = themePreviewState,
|
preview.theme == theme,
|
theme.supportsColorTuning,
|
!accessState.isUnlocked
|
else { return }
|
let updatedPreview = ProThemePreviewState(
|
theme: preview.theme,
|
themeTuning: theme.normalizedTuning(tuning),
|
startedAt: preview.startedAt,
|
endsAt: preview.endsAt
|
)
|
applyThemePreview(updatedPreview)
|
}
|
|
func stopThemePreview() {
|
themePreviewTask?.cancel()
|
themePreviewTask = nil
|
applyThemePreview(nil)
|
}
|
|
func startDisplayModePreview(for displayMode: String) {
|
guard ProEntitlementPolicy.isPremiumDisplayMode(displayMode) else { return }
|
guard !accessState.isUnlocked else { return }
|
|
stopThemePreview()
|
displayModePreviewTask?.cancel()
|
|
let now = Date()
|
let preview = ProDisplayModePreviewState(
|
displayMode: displayMode,
|
startedAt: now,
|
endsAt: now.addingTimeInterval(ProEntitlementConfig.themePreviewDuration)
|
)
|
applyDisplayModePreview(preview)
|
|
displayModePreviewTask = Task { [weak self] in
|
let duration = max(1, ProEntitlementConfig.themePreviewDuration)
|
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
|
guard !Task.isCancelled else { return }
|
self?.stopDisplayModePreview()
|
}
|
}
|
|
func stopDisplayModePreview() {
|
displayModePreviewTask?.cancel()
|
displayModePreviewTask = nil
|
applyDisplayModePreview(nil)
|
}
|
|
private func startTransactionUpdates() {
|
guard updateTask == nil else { return }
|
updateTask = Task { [weak self] in
|
for await update in Transaction.updates {
|
guard let self else { return }
|
if case .verified(let transaction) = update,
|
transaction.productID == ProEntitlementConfig.lifetimeProductID {
|
await transaction.finish()
|
}
|
await self.refreshEntitlements()
|
}
|
}
|
}
|
|
@discardableResult
|
private func loadProductIfNeeded() async throws -> Product? {
|
if let cachedProduct {
|
return cachedProduct
|
}
|
let products = try await Product.products(for: [ProEntitlementConfig.lifetimeProductID])
|
let product = products.first
|
cachedProduct = product
|
priceDisplayText = product?.displayPrice
|
return product
|
}
|
|
private func refreshEntitlements() async {
|
if let override = qaOverrideAccessState {
|
applyAccessState(override, persistCache: false)
|
operationState = .idle
|
return
|
}
|
|
let purchased = await hasPurchasedLifetimePro()
|
let appTransaction = await verifiedAppTransaction()
|
let legacy = appTransaction.map(isLegacyPaidUser(appTransaction:)) ?? false
|
|
let state: ProAccessState
|
if purchased {
|
state = .purchased
|
} else if legacy {
|
state = .legacy
|
} else {
|
state = .free
|
}
|
|
applyAccessState(
|
state,
|
persistCache: true,
|
originalAppVersion: appTransaction?.originalAppVersion,
|
originalPurchaseDate: appTransaction?.originalPurchaseDate
|
)
|
|
if case .loading = operationState {
|
operationState = .idle
|
}
|
}
|
|
private func hasPurchasedLifetimePro() async -> Bool {
|
for await verification in Transaction.currentEntitlements {
|
switch verification {
|
case .verified(let transaction):
|
if transaction.productID == ProEntitlementConfig.lifetimeProductID,
|
transaction.revocationDate == nil {
|
return true
|
}
|
case .unverified:
|
continue
|
}
|
}
|
return false
|
}
|
|
private func verifiedAppTransaction() async -> AppTransaction? {
|
do {
|
let verification = try await AppTransaction.shared
|
switch verification {
|
case .verified(let transaction):
|
if #available(macOS 13.0, *) {
|
if transaction.revocationDate != nil {
|
return nil
|
}
|
}
|
return transaction
|
case .unverified:
|
return nil
|
}
|
} catch {
|
return nil
|
}
|
}
|
|
private func isLegacyPaidUser(appTransaction: AppTransaction) -> Bool {
|
if let current = SemanticVersion(appTransaction.originalAppVersion),
|
let cutoff = SemanticVersion(ProEntitlementConfig.firstFreeProVersion) {
|
return current < cutoff
|
}
|
|
guard let fallbackDate = ProEntitlementConfig.legacyFallbackOriginalPurchaseDate else {
|
return false
|
}
|
return appTransaction.originalPurchaseDate < fallbackDate
|
}
|
|
private func applyAccessState(
|
_ state: ProAccessState,
|
persistCache: Bool,
|
originalAppVersion: String? = nil,
|
originalPurchaseDate: Date? = nil
|
) {
|
let previousState = accessState
|
accessState = state
|
ProEntitlementSnapshotStore.update { snapshot in
|
snapshot.accessState = state
|
}
|
|
if previousState != state {
|
NotificationCenter.default.post(name: .tagLauncherProEntitlementChanged, object: nil)
|
}
|
|
if persistCache {
|
writeCache(
|
ProEntitlementCache(
|
accessState: state,
|
updatedAt: Date(),
|
originalAppVersion: originalAppVersion,
|
originalPurchaseDate: originalPurchaseDate
|
)
|
)
|
}
|
}
|
|
private func applyThemePreview(_ preview: ProThemePreviewState?) {
|
themePreviewState = preview
|
ProEntitlementSnapshotStore.update { snapshot in
|
snapshot.previewThemeID = preview?.theme.rawValue
|
snapshot.previewThemeTuning = preview?.themeTuning
|
}
|
}
|
|
private func applyDisplayModePreview(_ preview: ProDisplayModePreviewState?) {
|
displayModePreviewState = preview
|
ProEntitlementSnapshotStore.update { snapshot in
|
snapshot.previewDisplayMode = preview?.displayMode
|
}
|
}
|
|
private var cacheURL: URL {
|
AppIdentity.applicationSupportDirectory
|
.appendingPathComponent("pro-entitlement-cache.json")
|
}
|
|
private func loadCache() -> ProEntitlementCache? {
|
guard let data = try? Data(contentsOf: cacheURL) else { return nil }
|
return try? JSONDecoder().decode(ProEntitlementCache.self, from: data)
|
}
|
|
private func writeCache(_ cache: ProEntitlementCache) {
|
try? FileManager.default.createDirectory(
|
at: AppIdentity.applicationSupportDirectory,
|
withIntermediateDirectories: true,
|
attributes: nil
|
)
|
guard let data = try? JSONEncoder().encode(cache) else { return }
|
try? data.write(to: cacheURL, options: .atomic)
|
}
|
|
private var qaOverrideAccessState: ProAccessState? {
|
guard let raw = ProcessInfo.processInfo.environment[ProEntitlementConfig.qaStateEnvKey] else {
|
return nil
|
}
|
switch raw {
|
case "free":
|
return .free
|
case "pro":
|
return .purchased
|
case "legacyPro":
|
return .legacy
|
default:
|
return nil
|
}
|
}
|
}
|
|
private struct SemanticVersion: Comparable {
|
private let components: [Int]
|
|
init?(_ raw: String) {
|
let values = raw
|
.split(separator: ".")
|
.compactMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
guard !values.isEmpty else { return nil }
|
components = values
|
}
|
|
static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
|
let maxCount = max(lhs.components.count, rhs.components.count)
|
for index in 0..<maxCount {
|
let left = index < lhs.components.count ? lhs.components[index] : 0
|
let right = index < rhs.components.count ? rhs.components[index] : 0
|
if left != right {
|
return left < right
|
}
|
}
|
return false
|
}
|
}
|
|
extension TagDatabase {
|
static func manualNonEmptyNotePaths(in store: Store) -> Set<String> {
|
Set(
|
store.appNotes.compactMap { path, note in
|
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard !trimmed.isEmpty,
|
store.appNoteMetadata[path]?.origin == .manual
|
else { return nil }
|
return path
|
}
|
)
|
}
|
|
static func manualNonEmptyNoteCount(in store: Store? = nil) -> Int {
|
let effectiveStore = store ?? load()
|
return manualNonEmptyNotePaths(in: effectiveStore).count
|
}
|
}
|