From 0ae3a9359e4cdf6ae5b669c4affd91662463f370 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 01 Jul 2026 23:17:36 +0800
Subject: [PATCH] Prepare TagLauncher 8.3.1 candidate
---
src/Apptag/Localization/fr.json | 17
src/Apptag/Localization/ru.json | 17
src/Docs/Requirements/free-vs-pro-feature-memo.md | 162 ++++
.gitignore | 5
src/Apptag/Localization/ar.json | 17
src/Apptag/Localization/ro.json | 17
src/Apptag/Localization/en.json | 17
src/Apptag/Localization/sv.json | 17
src/Scripts/pro_custom_container_quota_qa.sh | 143 +++
src/Apptag/Localization/zh-Hant.json | 17
src/Apptag/TagEditorView.swift | 29
src/Apptag/ApptagApp.swift | 7
src/Apptag/Localization/pl.json | 17
src/Apptag/Localization/es.json | 17
src/Apptag/Localization/th.json | 17
src/Apptag/Localization/nn.json | 17
src/Apptag/ProEntitlement.swift | 54 +
src/CHANGELOG.md | 17
src/Apptag/Info.plist | 4
src/Apptag/Localization/cs.json | 17
src/Apptag/Localization/ms.json | 17
src/Apptag/DataLayer.swift | 218 +++++
src/Apptag/Localization/it.json | 17
src/Apptag/Localization/tr.json | 17
src/Scripts/pro_feature_gate_qa.sh | 35
src/Scripts/settings_language_title_qa.sh | 87 ++
src/Apptag/Localization/da.json | 17
src/Apptag/Localization/id.json | 17
src/Apptag/Localization/ja.json | 17
src/Apptag/PreferencesView.swift | 208 +++++
src/Apptag/Localization/de.json | 17
src/Docs/Requirements/2026-07-01-pro-custom-container-quota-prd.md | 403 +++++++++++
src/Apptag/Localization/pt-BR.json | 17
CODEGRAPH.md | 4
src/Apptag/ProAccessViews.swift | 2
src/Apptag/Localization/ar-Najdi.json | 17
src/Apptag/Localization/nb.json | 17
src/Scripts/pro_localization_qa.sh | 20
src/Scripts/tag_editor_mouse_event_qa.sh | 81 ++
src/Apptag/Localization/ko.json | 17
src/Apptag/Localization/no.json | 17
src/Apptag/ContentView.swift | 168 ++++
src/Apptag/Localization/sr-Cyrl.json | 17
src/Apptag/Localization/zh-Hans.json | 17
src/Apptag/Localization/nl.json | 17
src/Apptag/Localization/uk.json | 17
src/Apptag/Localization/vi.json | 17
src/Scripts/pro_custom_hotkeys_qa.sh | 2
48 files changed, 2,049 insertions(+), 93 deletions(-)
diff --git a/.gitignore b/.gitignore
index 50527e8..6d3f3ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,11 @@
/canvas/
/文档导读.md
+# Local release snapshots can be large and noisy. Existing tracked release metadata
+# remains visible; force-add exceptional new release records only when needed.
+/src/Release/AppStore-*/
+/src/Release/Freeze-*/
+
# Local scratch notes.
/项目管理规范.md
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
index f9284d5..1c910f1 100644
--- a/CODEGRAPH.md
+++ b/CODEGRAPH.md
@@ -1,6 +1,6 @@
# TagLauncher CODEGRAPH
-最后更新:2026-06-30
+最后更新:2026-07-01
用途:本文件是 TagLauncher 的长期工程地图。修改用户可见行为、状态模型、构建/发布入口、核心源码或 QA 入口前,应先读本文件,再读指向的真实源码。若本文件与源码不一致,以源码为准,并在同一任务内更新本文件。
@@ -213,6 +213,8 @@
- 使用技巧关闭提醒、modal 防穿透、设置页预览、29 语种文案回归。
- `src/Scripts/window_logic_qa.sh`
- 窗口层级、显示隐藏、Dock/菜单栏相关逻辑回归;Dock 项断言按当前 QA 构建路径过滤,避免本机固定正式版 App 时误判重复图标。
+- `src/Scripts/settings_language_title_qa.sh`
+ - 设置窗口标题栏语言刷新静态门禁;检查创建标题、语言切换通知和恢复窗口 `onAppear` 都回写本地化 `NSWindow.title`。
- `src/Scripts/app_ordering_data_qa.sh`
- App 排序数据持久化回归。
diff --git a/src/Apptag/ApptagApp.swift b/src/Apptag/ApptagApp.swift
index 0166772..2ef8916 100644
--- a/src/Apptag/ApptagApp.swift
+++ b/src/Apptag/ApptagApp.swift
@@ -2169,12 +2169,15 @@
// NSHostingView.hitTest may return a SwiftUI-internal wrapper — the
// actual AppKit subview may be nested deeper.
if let container = findTextFieldContainer(in: hit) {
- container.mouseDown(with: event)
+ container.focusTextField()
return
}
if let tf = findNSTextField(in: hit) {
+ let shouldSelectAll = tf.currentEditor() == nil
tf.window?.makeFirstResponder(tf)
- tf.mouseDown(with: event)
+ if shouldSelectAll {
+ tf.selectText(nil)
+ }
return
}
super.mouseDown(with: event)
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 4a9cd47..3fede8e 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -38,8 +38,8 @@
// MARK: - Native NSTextField (avoids SwiftUI TextField event issues)
-/// Custom container that wraps NSTextField so that hitTest returns the container
-/// and mouseDown can reliably make the text field first responder.
+/// Custom container that wraps NSTextField so first focus is reliable while
+/// subsequent clicks keep native caret placement and selection behavior.
final class TextFieldContainer: NSView {
let textField: NSTextField
@@ -58,14 +58,33 @@
textField.frame = bounds
}
+ private var isEditing: Bool {
+ guard let window else { return false }
+ return window.firstResponder === textField || textField.currentEditor() != nil
+ }
+
override func hitTest(_ point: NSPoint) -> NSView? {
- if bounds.contains(point) { return self }
- return nil
+ guard bounds.contains(point) else { return nil }
+ if isEditing {
+ let fieldPoint = textField.convert(point, from: self)
+ return textField.hitTest(fieldPoint) ?? textField
+ }
+ return self
+ }
+
+ func focusTextField() {
+ guard let w = window else { return }
+ let shouldSelectAll = !isEditing
+ w.makeFirstResponder(textField)
+ if shouldSelectAll {
+ textField.selectText(nil)
+ }
}
override func mouseDown(with event: NSEvent) {
- if let w = window { w.makeFirstResponder(textField) }
- textField.mouseDown(with: event)
+ // Do not forward this same NSEvent to NSTextField.mouseDown. AppKit can
+ // forward it back to this container during control tracking and recurse.
+ focusTextField()
}
}
@@ -374,6 +393,7 @@
let id = UUID()
let feature: ProFeature
let noteQuotaStatus: ProNoteQuotaStatus?
+ let customContainerQuotaStatus: ProCustomContainerQuotaStatus?
}
struct ContentView: View {
@@ -387,6 +407,7 @@
@State private var tagColors: [String: Int] = [:]
@State private var tagCustomColors: [String: TagCustomColor] = [:]
@State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
+ @State private var customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus()
@State private var containerAppOrder: [String: [String]] = [:]
@State private var groupLayoutVersion = 0
@State private var appGridScrollTargetID: String? = nil
@@ -697,11 +718,11 @@
benefitText: tr(prompt.feature.benefitKey),
operationText: proEntitlement.operationState.statusMessageKey.map(tr),
unlockTitle: tr("pro.card.unlock"),
- restoreTitle: tr("pro.card.restore"),
+ restoreTitle: proPromptSecondaryTitle(for: prompt),
isBusy: proEntitlement.operationState.isBusy,
onClose: dismissProPrompt,
onUnlock: { proEntitlement.purchasePro() },
- onRestore: { proEntitlement.restorePurchases() }
+ onRestore: { handleProPromptSecondaryAction(prompt) }
)
}
.transition(.opacity)
@@ -757,6 +778,7 @@
tagCustomColors = store.tags.compactMapValues { $0.customColor }
tagDefinitions = store.tags
draggedTagNames = TagEditor.orderedTagNames()
+ refreshCustomContainerQuotaStatus(in: store)
}
editPhase = phase
if phase == .none {
@@ -1399,9 +1421,47 @@
excludedTagNames: ["Mac自带", defaultGroupName],
isCustomColorUnlocked: proEntitlement.isUnlocked,
onLockedCustomColor: { presentProPrompt(for: .customTagColors) },
- onRefresh: { refreshApps() }
+ onCustomContainerQuotaExceeded: presentCustomContainerQuotaPrompt,
+ onCustomContainerQuotaStatusChanged: refreshCustomContainerQuotaStatus,
+ topLeadingAccessory: AnyView(customContainerQuotaAccessory),
+ onRefresh: {
+ refreshCustomContainerQuotaStatus()
+ refreshApps()
+ }
)
}
+ }
+
+ private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
+ if proEntitlement.isUnlocked {
+ return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
+ }
+ return customContainerQuotaStatus
+ }
+
+ private var customContainerQuotaAccessory: some View {
+ let status = effectiveCustomContainerQuotaStatus
+ let text = status.isUnlimited
+ ? tr("pro.customContainers.quotaUnlimitedShort")
+ : tr(
+ "pro.customContainers.quotaShort",
+ replacements: [
+ "%used%": "\(status.used)",
+ "%limit%": "\(status.limit)"
+ ]
+ )
+ return Text(text)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ private func refreshCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
+ customContainerQuotaStatus = status
+ }
+
+ private func refreshCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
+ customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
}
// MARK: - Edit Apps View
@@ -1553,7 +1613,7 @@
.onEnded { _ in
guard canReorderTag(tagName) else { return }
dragItem = nil
- TagEditor.reorderTags(draggedTagNames)
+ persistTagOrderIfAllowed()
}
}
@@ -1581,9 +1641,11 @@
switch editTagOperation {
case .add:
- TagEditor.appendTags(tags, to: paths)
+ let result = TagEditor.appendTags(tags, to: paths)
+ guard handleTagMutationResult(result) else { return }
case .remove:
- TagEditor.removeTags(tags, from: paths)
+ let result = TagEditor.removeTags(tags, from: paths)
+ guard handleTagMutationResult(result) else { return }
}
selectedAppPaths = []
selectedTagNames = []
@@ -2231,16 +2293,68 @@
status.remaining <= 0 {
return tr("pro.notes.limitReached")
}
+ if prompt.feature == .customContainerQuota,
+ let status = prompt.customContainerQuotaStatus,
+ !status.isUnlimited {
+ return tr(
+ "pro.customContainers.quotaPrompt",
+ replacements: [
+ "%used%": "\(status.used)",
+ "%limit%": "\(status.limit)",
+ "%remaining%": "\(status.remaining)"
+ ]
+ )
+ }
return tr(prompt.feature.promptMessageKey)
}
- private func presentProPrompt(for feature: ProFeature, noteQuotaStatus: ProNoteQuotaStatus? = nil) {
+ private func proPromptSecondaryTitle(for prompt: PendingProPrompt) -> String {
+ prompt.feature == .customContainerQuota
+ ? tr("pro.customContainers.manageExisting")
+ : tr("pro.card.restore")
+ }
+
+ private func handleProPromptSecondaryAction(_ prompt: PendingProPrompt) {
+ if prompt.feature == .customContainerQuota {
+ dismissProPrompt()
+ setEditPhase(.editingTags)
+ return
+ }
+ proEntitlement.restorePurchases()
+ }
+
+ private func presentProPrompt(
+ for feature: ProFeature,
+ noteQuotaStatus: ProNoteQuotaStatus? = nil,
+ customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
+ ) {
proEntitlement.clearTransientOperationState()
withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
appGridInteraction.proPrompt = PendingProPrompt(
feature: feature,
- noteQuotaStatus: noteQuotaStatus
+ noteQuotaStatus: noteQuotaStatus,
+ customContainerQuotaStatus: customContainerQuotaStatus
)
+ }
+ }
+
+ private func presentCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
+ presentProPrompt(
+ for: .customContainerQuota,
+ customContainerQuotaStatus: status
+ )
+ }
+
+ private func handleTagMutationResult(_ result: TagEditorMutationResult) -> Bool {
+ switch result {
+ case .saved:
+ refreshCustomContainerQuotaStatus()
+ return true
+ case .noChange:
+ return false
+ case .blockedCustomContainerQuota(let status):
+ presentCustomContainerQuotaPrompt(status)
+ return false
}
}
@@ -2530,11 +2644,22 @@
let hadDragState = tagNavDragModeActive || tagNavDragItem != nil
guard hadDragState else { return }
if tagNavDragModeActive && tagNavReorderDidMove {
- TagEditor.reorderTags(draggedTagNames)
+ persistTagOrderIfAllowed()
}
tagNavDragModeActive = false
tagNavDragItem = nil
tagNavReorderDidMove = false
+ }
+
+ private func persistTagOrderIfAllowed() {
+ guard proEntitlement.isUnlocked else {
+ showDropWarning(message: tr("pro.tagSorting.previewToast"))
+ return
+ }
+ guard TagEditor.reorderTags(draggedTagNames) else {
+ showDropWarning(message: tr("pro.tagSorting.previewToast"))
+ return
+ }
}
private func cancelTagNavReorderVisualState() {
@@ -2582,13 +2707,14 @@
}
guard tagColors[targetTag] != nil else { return }
- TagEditor.moveApp(
+ let result = TagEditor.moveApp(
path: path,
from: sourceTag,
to: targetTag,
color: tagColors[targetTag] ?? 0,
copy: copy
)
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2598,7 +2724,8 @@
guard canAssignDroppedAppToTag(targetTag) else { return }
guard let app = allApps.first(where: { $0.path.path == path }) else { return }
guard !appHasTag(app, tagName: targetTag) else { return }
- TagEditor.appendTags([targetTag], to: [path])
+ let result = TagEditor.appendTags([targetTag], to: [path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2735,7 +2862,8 @@
private func moveDroppedAppToUncategorized(path: String, tags: [String]) {
guard !tags.isEmpty else { return }
- TagEditor.removeTags(tags, from: [path])
+ let result = TagEditor.removeTags(tags, from: [path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2802,7 +2930,8 @@
guard isRemovableRegularTag(tagName),
appHasTag(app, tagName: tagName)
else { return }
- TagEditor.removeTags([tagName], from: [app.path.path])
+ let result = TagEditor.removeTags([tagName], from: [app.path.path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2981,6 +3110,7 @@
tagCustomColors = snapshot.tagDefinitions.compactMapValues { $0.customColor }
containerAppOrder = snapshot.containerAppOrder
draggedTagNames = snapshot.tagOrder
+ refreshCustomContainerQuotaStatus()
rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
if quickSearchVisible {
refreshQuickSearchResults()
diff --git a/src/Apptag/DataLayer.swift b/src/Apptag/DataLayer.swift
index 2a23bbd..ac1650c 100644
--- a/src/Apptag/DataLayer.swift
+++ b/src/Apptag/DataLayer.swift
@@ -1024,6 +1024,7 @@
var color: Int
var systemCategoryID: SmartCategoryID? = nil
var customColor: TagCustomColor? = nil
+ var isCustomMaintained: Bool? = nil
}
struct Store: Codable {
@@ -1174,6 +1175,40 @@
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
guard let data = try? encoder.encode(normalizedStore) else { return }
try? data.write(to: storeURL, options: .atomic)
+ }
+
+ static func customMaintainedContainerCount(in store: Store? = nil) -> Int {
+ customMaintainedContainerNames(in: store).count
+ }
+
+ static func customMaintainedContainerNames(in store: Store? = nil) -> [String] {
+ let effectiveStore = store ?? load()
+ return effectiveStore.tags
+ .filter { isCustomMaintainedTag(name: $0.key, definition: $0.value) }
+ .map(\.key)
+ .sorted()
+ }
+
+ static func isCustomMaintainedTag(name: String, definition: TagDef) -> Bool {
+ guard let systemCategoryID = definition.systemCategoryID else {
+ return true
+ }
+ if definition.isCustomMaintained == true {
+ return true
+ }
+ if definition.customColor != nil {
+ return true
+ }
+ return definition.color != systemCategoryID.defaultColorIndex
+ }
+
+ static func markCustomMaintainedIfNeeded(_ tagName: String, in store: inout Store) {
+ guard var definition = store.tags[tagName],
+ definition.systemCategoryID != nil,
+ !isCustomMaintainedTag(name: tagName, definition: definition)
+ else { return }
+ definition.isCustomMaintained = true
+ store.tags[tagName] = definition
}
static func normalizedAppOrderPaths(_ paths: [String]) -> [String] {
@@ -1528,6 +1563,7 @@
for (tagName, tagDef) in store.tags {
guard let categoryID = tagDef.systemCategoryID else { continue }
+ guard !isCustomMaintainedTag(name: tagName, definition: tagDef) else { continue }
let targetName = categoryID.localizedDisplayName
guard targetName != tagName else { continue }
if let existing = store.tags[targetName],
@@ -1555,7 +1591,9 @@
) -> (name: String, created: Bool)? {
if let existingName = systemTagName(for: categoryID, in: store) {
let targetName = categoryID.localizedDisplayName
- if existingName != targetName {
+ let existingDefinition = store.tags[existingName]
+ if existingName != targetName,
+ existingDefinition.map({ !isCustomMaintainedTag(name: existingName, definition: $0) }) ?? true {
if let existing = store.tags[targetName],
existing.systemCategoryID != categoryID {
return (existingName, false)
@@ -1832,9 +1870,55 @@
}
}
+enum TagEditorMutationResult {
+ case saved
+ case noChange
+ case blockedCustomContainerQuota(ProCustomContainerQuotaStatus)
+
+ var didSave: Bool {
+ if case .saved = self {
+ return true
+ }
+ return false
+ }
+}
+
// MARK: - Tag Editor (CRUD against local TagDatabase)
enum TagEditor {
+ private static func customContainerCandidates(
+ _ tagNames: Set<String>,
+ in store: TagDatabase.Store
+ ) -> Set<String> {
+ Set(tagNames.filter { tagName in
+ guard let definition = store.tags[tagName] else {
+ return true
+ }
+ return !TagDatabase.isCustomMaintainedTag(name: tagName, definition: definition)
+ })
+ }
+
+ private static func customContainerMaintenanceResult(
+ for tagNames: Set<String>,
+ in store: TagDatabase.Store
+ ) -> TagEditorMutationResult? {
+ let addedCount = customContainerCandidates(tagNames, in: store).count
+ switch ProEntitlementPolicy.customContainerMaintenanceDecision(
+ addingCustomContainerCount: addedCount,
+ in: store
+ ) {
+ case .allow:
+ return nil
+ case .blocked(let status):
+ return .blockedCustomContainerQuota(status)
+ }
+ }
+
+ private static func markCustomMaintained(_ tagNames: Set<String>, in store: inout TagDatabase.Store) {
+ for tagName in tagNames {
+ TagDatabase.markCustomMaintainedIfNeeded(tagName, in: &store)
+ }
+ }
// MARK: Read
@@ -1857,7 +1941,9 @@
}
/// Persist a new tag display order.
- static func reorderTags(_ names: [String]) {
+ @discardableResult
+ static func reorderTags(_ names: [String]) -> Bool {
+ guard ProEntitlementPolicy.isUnlocked(.persistentAppSorting) else { return false }
var store = TagDatabase.load()
let previousStore = store
store.tagOrder = names
@@ -1866,6 +1952,7 @@
previous: previousStore,
reason: "reorder-tags"
)
+ return true
}
/// Persist the visible app order for one stable container.
@@ -1896,9 +1983,13 @@
}
/// Create a new tag definition (no app assignments yet).
- static func createTag(_ name: String, color: Int) {
+ @discardableResult
+ static func createTag(_ name: String, color: Int) -> TagEditorMutationResult {
var store = TagDatabase.load()
- guard store.tags[name] == nil else { return }
+ guard store.tags[name] == nil else { return .noChange }
+ if let blocked = customContainerMaintenanceResult(for: [name], in: store) {
+ return blocked
+ }
let previousStore = store
store.tags[name] = TagDatabase.TagDef(color: color, systemCategoryID: nil)
if !store.tagOrder.contains(name) { store.tagOrder.insert(name, at: 0) }
@@ -1907,6 +1998,7 @@
previous: previousStore,
reason: "create-tag"
)
+ return .saved
}
/// Annotate scanned apps with tags from the database.
@@ -1935,8 +2027,12 @@
// MARK: Write
/// Assign a tag to multiple apps. Preserves existing tags.
- static func assignTag(_ tag: String, color: Int, to paths: [String]) {
+ @discardableResult
+ static func assignTag(_ tag: String, color: Int, to paths: [String]) -> TagEditorMutationResult {
var store = TagDatabase.load()
+ if let blocked = customContainerMaintenanceResult(for: [tag], in: store) {
+ return blocked
+ }
let previousStore = store
// Ensure tag definition exists
if store.tags[tag] == nil {
@@ -1944,6 +2040,7 @@
// New tag: append to display order
if !store.tagOrder.contains(tag) { store.tagOrder.insert(tag, at: 0) }
}
+ markCustomMaintained([tag], in: &store)
for path in paths {
var current = store.appTags[path] ?? []
if !current.contains(tag) {
@@ -1956,14 +2053,27 @@
previous: previousStore,
reason: "assign-tag"
)
+ return .saved
}
/// Replace the full editable tag set for multiple apps.
- static func setTags(_ tags: [String], to paths: [String]) {
+ @discardableResult
+ static func setTags(_ tags: [String], to paths: [String]) -> TagEditorMutationResult {
var store = TagDatabase.load()
- let previousStore = store
let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
let validTags = tags.filter { store.tags[$0] != nil }
+ let nextValidTags = Set(validTags)
+ var touchedTags = Set<String>()
+ for path in paths {
+ let currentTags = Set(store.appTags[path] ?? [])
+ touchedTags.formUnion(currentTags.symmetricDifference(nextValidTags))
+ }
+ touchedTags = touchedTags.filter { store.tags[$0] != nil }
+ if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
+ return blocked
+ }
+ let previousStore = store
+ markCustomMaintained(touchedTags, in: &store)
var uncommonPaths = Set(store.uncommonAppPaths)
for path in paths {
if validTags.isEmpty {
@@ -1985,16 +2095,29 @@
previous: previousStore,
reason: "set-tags"
)
+ return .saved
}
/// Append tags to multiple apps without disturbing their existing tag sets.
- static func appendTags(_ tags: [String], to paths: [String]) {
- guard !tags.isEmpty, !paths.isEmpty else { return }
+ @discardableResult
+ static func appendTags(_ tags: [String], to paths: [String]) -> TagEditorMutationResult {
+ guard !tags.isEmpty, !paths.isEmpty else { return .noChange }
var store = TagDatabase.load()
- let previousStore = store
let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
let validTags = tags.filter { store.tags[$0] != nil }
+ var touchedTags = Set<String>()
+ for path in paths {
+ let current = Set(store.appTags[path] ?? [])
+ for tag in validTags where !current.contains(tag) {
+ touchedTags.insert(tag)
+ }
+ }
+ if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
+ return blocked
+ }
+ let previousStore = store
+ markCustomMaintained(touchedTags, in: &store)
var uncommonPaths = Set(store.uncommonAppPaths)
for path in paths {
@@ -2018,16 +2141,27 @@
previous: previousStore,
reason: "append-tags"
)
+ return .saved
}
/// Remove tags from multiple apps while preserving every unrelated tag.
- static func removeTags(_ tags: [String], from paths: [String]) {
- guard !tags.isEmpty, !paths.isEmpty else { return }
+ @discardableResult
+ static func removeTags(_ tags: [String], from paths: [String]) -> TagEditorMutationResult {
+ guard !tags.isEmpty, !paths.isEmpty else { return .noChange }
var store = TagDatabase.load()
- let previousStore = store
let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
let validTags = Set(tags.filter { store.tags[$0] != nil })
+ var touchedTags = Set<String>()
+ for path in paths {
+ let current = Set(store.appTags[path] ?? [])
+ touchedTags.formUnion(validTags.intersection(current))
+ }
+ if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
+ return blocked
+ }
+ let previousStore = store
+ markCustomMaintained(touchedTags, in: &store)
var uncommonPaths = Set(store.uncommonAppPaths)
for path in paths {
@@ -2052,6 +2186,7 @@
previous: previousStore,
reason: "remove-tags"
)
+ return .saved
}
static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store {
@@ -2245,13 +2380,30 @@
return decision
}
- static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) {
+ @discardableResult
+ static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) -> TagEditorMutationResult {
var store = TagDatabase.load()
+ var touchedTags = Set<String>()
+ let currentTags = Set(store.appTags[path] ?? [])
+ if store.tags[targetTag] == nil || !currentTags.contains(targetTag) {
+ touchedTags.insert(targetTag)
+ }
+ if !copy,
+ !sourceTag.isEmpty,
+ sourceTag != "Mac自带",
+ sourceTag != tr("group.appleBuiltIn"),
+ currentTags.contains(sourceTag) {
+ touchedTags.insert(sourceTag)
+ }
+ if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
+ return blocked
+ }
let previousStore = store
if store.tags[targetTag] == nil {
store.tags[targetTag] = TagDatabase.TagDef(color: color, systemCategoryID: nil)
if !store.tagOrder.contains(targetTag) { store.tagOrder.insert(targetTag, at: 0) }
}
+ markCustomMaintained(touchedTags, in: &store)
var current = store.appTags[path] ?? []
if !copy, !sourceTag.isEmpty, sourceTag != "Mac自带", sourceTag != tr("group.appleBuiltIn") {
@@ -2271,17 +2423,31 @@
previous: previousStore,
reason: "move-app"
)
+ return .saved
}
/// Rename a tag on all apps that have it.
- static func renameTag(from oldName: String, to newName: String) {
+ @discardableResult
+ static func renameTag(from oldName: String, to newName: String) -> TagEditorMutationResult {
var store = TagDatabase.load()
- guard oldName != newName, store.tags[newName] == nil else { return }
+ guard oldName != newName, store.tags[newName] == nil else { return .noChange }
+ guard let oldDefinition = store.tags[oldName] else { return .noChange }
+ let needsCustomContainerSlot = !TagDatabase.isCustomMaintainedTag(
+ name: oldName,
+ definition: oldDefinition
+ )
+ if needsCustomContainerSlot,
+ let blocked = customContainerMaintenanceResult(for: [oldName], in: store) {
+ return blocked
+ }
let previousStore = store
// Update tag definition
- if let def = store.tags.removeValue(forKey: oldName) {
+ if var def = store.tags.removeValue(forKey: oldName) {
let oldContainerID = AppContainerID.forTag(oldName, definition: def)
let newContainerID = AppContainerID.forTag(newName, definition: def)
+ if def.systemCategoryID != nil {
+ def.isCustomMaintained = true
+ }
store.tags[newName] = def
TagDatabase.migrateContainerAppOrderKey(
in: &store,
@@ -2303,6 +2469,7 @@
previous: previousStore,
reason: "rename-tag"
)
+ return .saved
}
/// Delete a tag completely (from all apps and tag definitions).
@@ -2343,18 +2510,29 @@
}
/// Set the color for a tag.
- static func setColor(_ color: Int, for tag: String) {
+ @discardableResult
+ static func setColor(_ color: Int, for tag: String) -> TagEditorMutationResult {
var store = TagDatabase.load()
- let previousStore = store
- var tagDef = store.tags[tag] ?? TagDatabase.TagDef(color: color, systemCategoryID: nil)
+ let existingDefinition = store.tags[tag]
+ var tagDef = existingDefinition ?? TagDatabase.TagDef(color: color, systemCategoryID: nil)
+ let wasCustomMaintained = existingDefinition.map {
+ TagDatabase.isCustomMaintainedTag(name: tag, definition: $0)
+ } ?? false
tagDef.color = color
tagDef.customColor = nil
+ let willBeCustomMaintained = TagDatabase.isCustomMaintainedTag(name: tag, definition: tagDef)
+ if !wasCustomMaintained, willBeCustomMaintained,
+ let blocked = customContainerMaintenanceResult(for: [tag], in: store) {
+ return blocked
+ }
+ let previousStore = store
store.tags[tag] = tagDef
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
reason: "set-tag-color"
)
+ return .saved
}
/// Set a Pro custom color for a tag without changing its fallback base color.
diff --git a/src/Apptag/Info.plist b/src/Apptag/Info.plist
index 99e9171..acf4f6c 100644
--- a/src/Apptag/Info.plist
+++ b/src/Apptag/Info.plist
@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>8.2.9</string>
+ <string>8.3.1</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -32,7 +32,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
- <string>20260630.1617</string>
+ <string>20260701.2302</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
diff --git a/src/Apptag/Localization/ar-Najdi.json b/src/Apptag/Localization/ar-Najdi.json
index eefed37..567f86f 100644
--- a/src/Apptag/Localization/ar-Najdi.json
+++ b/src/Apptag/Localization/ar-Najdi.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "غير متاح",
"settings.proStatus.previewFiveMinutes": "معاينة لمدة 5 دقائق فقط",
- "settings.proGuide.general": "يمكن لمستخدمي Pro فقط استخدام الحاويات/الشبكات الملونة"
+ "settings.proGuide.general": "يمكن لمستخدمي Pro فقط استخدام الحاويات/الشبكات الملونة",
+ "pro.feature.customContainerQuota": "حد الحاويات المخصصة",
+ "pro.feature.customContainerQuota.benefit": "الإصدار المجاني يسمح بإدارة 6 حاويات مخصصة. Pro يفتح إدارة وسوم بدون حدود.",
+ "pro.prompt.customContainerQuota": "حد الحاويات المخصصة من مزايا Pro. افتح Pro لإنشاء وإدارة وسوم بلا حدود.",
+ "pro.customContainers.quotaShort": "يقدر المستخدم المجاني يستخدم 6 وسوم مخصصة (حاويات): %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "تم فتح Pro: حاويات مخصصة بلا حدود",
+ "pro.customContainers.quotaPrompt": "الإصدار المجاني يسمح بإدارة %limit% حاويات مخصصة. استخدمت %used% / %limit%، وباقي %remaining%. رقِّ إلى Pro لإدارة وسوم بلا حدود، أو عدّل/احذف الوسوم الحالية للاستمرار ضمن الحصة المجانية.",
+ "pro.customContainers.manageExisting": "إدارة الوسوم الحالية",
+ "pro.tagSorting.previewToast": "ترتيب الوسوم الحالي للمعاينة فقط. افتح Pro لحفظ الترتيب.",
+ "pro.feature.maintainableTagCount": "عدد الوسوم القابلة للإدارة",
+ "pro.feature.tagOrderPersistence": "ترتيب مخصص: موضع الوسم في القائمة (= موضع الحاوية)",
+ "pro.feature.containerAppOrderPersistence": "ترتيب مخصص: مواضع أيقونات التطبيقات داخل الحاويات",
+ "settings.proStatus.maxSixTags": "حتى 6",
+ "settings.proStatus.unlimitedTags": "✅ بلا حد",
+ "settings.proStatus.previewOnly": "معاينة فقط",
+ "settings.proStatus.supported": "✅ مدعوم"
}
diff --git a/src/Apptag/Localization/ar.json b/src/Apptag/Localization/ar.json
index be74fc2..e1f14ef 100644
--- a/src/Apptag/Localization/ar.json
+++ b/src/Apptag/Localization/ar.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "غير متاح",
"settings.proStatus.previewFiveMinutes": "معاينة لمدة 5 دقائق فقط",
- "settings.proGuide.general": "يمكن لمستخدمي Pro فقط استخدام الحاويات/الشبكات الملونة"
+ "settings.proGuide.general": "يمكن لمستخدمي Pro فقط استخدام الحاويات/الشبكات الملونة",
+ "pro.feature.customContainerQuota": "حد الحاويات المخصصة",
+ "pro.feature.customContainerQuota.benefit": "يتيح الإصدار المجاني إدارة 6 حاويات مخصصة. يفتح Pro إدارة غير محدودة للوسوم.",
+ "pro.prompt.customContainerQuota": "حد الحاويات المخصصة من ميزات Pro. افتح Pro لإنشاء وإدارة وسوم بلا حدود.",
+ "pro.customContainers.quotaShort": "يمكن للمستخدمين المجانيين استخدام 6 وسوم مخصصة (حاويات): %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "تم فتح Pro: حاويات مخصصة بلا حدود",
+ "pro.customContainers.quotaPrompt": "يتيح الإصدار المجاني إدارة %limit% حاويات مخصصة. استخدمت %used% / %limit%، والمتبقي %remaining%. قم بالترقية إلى Pro لإدارة وسوم بلا حدود، أو عدّل/احذف الوسوم الحالية للاستمرار ضمن الحصة المجانية.",
+ "pro.customContainers.manageExisting": "إدارة الوسوم الحالية",
+ "pro.tagSorting.previewToast": "ترتيب الوسوم الحالي للمعاينة فقط. افتح Pro لحفظ الترتيب.",
+ "pro.feature.maintainableTagCount": "عدد الوسوم القابلة للإدارة",
+ "pro.feature.tagOrderPersistence": "ترتيب مخصص: موضع الوسم في القائمة (= موضع الحاوية)",
+ "pro.feature.containerAppOrderPersistence": "ترتيب مخصص: مواضع أيقونات التطبيقات داخل الحاويات",
+ "settings.proStatus.maxSixTags": "حتى 6",
+ "settings.proStatus.unlimitedTags": "✅ بلا حد",
+ "settings.proStatus.previewOnly": "معاينة فقط",
+ "settings.proStatus.supported": "✅ مدعوم"
}
diff --git a/src/Apptag/Localization/cs.json b/src/Apptag/Localization/cs.json
index 13c8264..8f09d1e 100644
--- a/src/Apptag/Localization/cs.json
+++ b/src/Apptag/Localization/cs.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Nedostupné",
"settings.proStatus.previewFiveMinutes": "Pouze 5minutový náhled",
- "settings.proGuide.general": "Barevné kontejnery / mřížky mohou používat jen uživatelé Pro"
+ "settings.proGuide.general": "Barevné kontejnery / mřížky mohou používat jen uživatelé Pro",
+ "pro.feature.customContainerQuota": "Limit vlastních kontejnerů",
+ "pro.feature.customContainerQuota.benefit": "Bezplatná verze umožňuje spravovat 6 vlastních kontejnerů. Pro odemkne neomezenou správu štítků.",
+ "pro.prompt.customContainerQuota": "Limit vlastních kontejnerů je funkce Pro. Odemkněte Pro pro neomezené vytváření a správu štítků.",
+ "pro.customContainers.quotaShort": "Bezplatní uživatelé mohou používat 6 vlastních štítků (kontejnerů): využito %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "Pro odemčeno: vlastní kontejnery bez omezení",
+ "pro.customContainers.quotaPrompt": "Bezplatná verze umožňuje spravovat %limit% vlastních kontejnerů. Využito %used% / %limit%, zbývá %remaining%. Upgradujte na Pro pro neomezené štítky, nebo upravte/smažte stávající štítky a pokračujte v bezplatném limitu.",
+ "pro.customContainers.manageExisting": "Spravovat stávající štítky",
+ "pro.tagSorting.previewToast": "Aktuální pořadí štítků je jen náhled. Odemkněte Pro pro uložení pořadí.",
+ "pro.feature.maintainableTagCount": "Počet spravovatelných štítků",
+ "pro.feature.tagOrderPersistence": "Vlastní řazení: pozice štítku v seznamu (= pozice kontejneru)",
+ "pro.feature.containerAppOrderPersistence": "Vlastní řazení: pozice ikon aplikací v kontejnerech",
+ "settings.proStatus.maxSixTags": "Až 6",
+ "settings.proStatus.unlimitedTags": "✅ Bez omezení",
+ "settings.proStatus.previewOnly": "Jen náhled",
+ "settings.proStatus.supported": "✅ Podporováno"
}
diff --git a/src/Apptag/Localization/da.json b/src/Apptag/Localization/da.json
index f445cc6..2c1cc90 100644
--- a/src/Apptag/Localization/da.json
+++ b/src/Apptag/Localization/da.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Ikke tilgængelig",
"settings.proStatus.previewFiveMinutes": "Kun 5 minutters forhåndsvisning",
- "settings.proGuide.general": "Kun Pro-brugere kan bruge farvede beholdere / gitre"
+ "settings.proGuide.general": "Kun Pro-brugere kan bruge farvede beholdere / gitre",
+ "pro.feature.customContainerQuota": "Kvota for egne beholdere",
+ "pro.feature.customContainerQuota.benefit": "Gratisversionen kan vedligeholde 6 egne beholdere. Pro låser op for ubegrænset tagstyring.",
+ "pro.prompt.customContainerQuota": "Kvoten for egne beholdere er en Pro-funktion. Lås Pro op for at oprette og administrere ubegrænsede tags.",
+ "pro.customContainers.quotaShort": "Gratisbrugere kan bruge 6 egne tags (beholdere): %used% / %limit% brugt",
+ "pro.customContainers.quotaUnlimitedShort": "Pro låst op: ubegrænsede egne beholdere",
+ "pro.customContainers.quotaPrompt": "Gratisversionen kan vedligeholde %limit% egne beholdere. Du har brugt %used% / %limit%, der er %remaining% tilbage. Opgrader til Pro for ubegrænsede tags, eller rediger/slet eksisterende tags for at fortsætte med gratiskvoten.",
+ "pro.customContainers.manageExisting": "Administrer eksisterende tags",
+ "pro.tagSorting.previewToast": "Den aktuelle tagrækkefølge er kun en forhåndsvisning. Lås Pro op for at gemme den.",
+ "pro.feature.maintainableTagCount": "Antal vedligeholdelige tags",
+ "pro.feature.tagOrderPersistence": "Egen sortering: tagposition på listen (= beholderposition)",
+ "pro.feature.containerAppOrderPersistence": "Egen sortering: appikoners placering i beholdere",
+ "settings.proStatus.maxSixTags": "Op til 6",
+ "settings.proStatus.unlimitedTags": "✅ Ingen grænse",
+ "settings.proStatus.previewOnly": "Kun forhåndsvisning",
+ "settings.proStatus.supported": "✅ Understøttet"
}
diff --git a/src/Apptag/Localization/de.json b/src/Apptag/Localization/de.json
index 9bd3430..a1ee613 100644
--- a/src/Apptag/Localization/de.json
+++ b/src/Apptag/Localization/de.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Nicht verfügbar",
"settings.proStatus.previewFiveMinutes": "Nur 5 Minuten Vorschau",
- "settings.proGuide.general": "Nur Pro-Nutzer können farbige Container / Raster verwenden"
+ "settings.proGuide.general": "Nur Pro-Nutzer können farbige Container / Raster verwenden",
+ "pro.feature.customContainerQuota": "Kontingent für eigene Container",
+ "pro.feature.customContainerQuota.benefit": "Die kostenlose Version kann 6 eigene Container verwalten. Pro schaltet unbegrenzte Tag-Verwaltung frei.",
+ "pro.prompt.customContainerQuota": "Das Kontingent für eigene Container ist Teil von Pro. Schalte Pro frei, um unbegrenzt Tags zu erstellen und zu verwalten.",
+ "pro.customContainers.quotaShort": "Kostenlose Nutzer können 6 eigene Tags (Container) verwenden: %used% / %limit% genutzt",
+ "pro.customContainers.quotaUnlimitedShort": "Pro freigeschaltet: unbegrenzte eigene Container",
+ "pro.customContainers.quotaPrompt": "Die kostenlose Version kann %limit% eigene Container verwalten. Verwendet: %used% / %limit%, verbleibend: %remaining%. Upgrade auf Pro für unbegrenzte Tags, oder bearbeite/lösche vorhandene Tags, um das kostenlose Kontingent weiter zu nutzen.",
+ "pro.customContainers.manageExisting": "Vorhandene Tags verwalten",
+ "pro.tagSorting.previewToast": "Die aktuelle Tag-Reihenfolge ist nur eine Vorschau. Schalte Pro frei, um sie zu speichern.",
+ "pro.feature.maintainableTagCount": "Anzahl verwaltbarer Tags",
+ "pro.feature.tagOrderPersistence": "Eigene Sortierung: Tag-Position in der Liste (= Container-Position)",
+ "pro.feature.containerAppOrderPersistence": "Eigene Sortierung: App-Symbolpositionen in Containern",
+ "settings.proStatus.maxSixTags": "Bis zu 6",
+ "settings.proStatus.unlimitedTags": "✅ Keine Begrenzung",
+ "settings.proStatus.previewOnly": "Nur Vorschau",
+ "settings.proStatus.supported": "✅ Unterstützt"
}
diff --git a/src/Apptag/Localization/en.json b/src/Apptag/Localization/en.json
index f741320..83e8723 100644
--- a/src/Apptag/Localization/en.json
+++ b/src/Apptag/Localization/en.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Unavailable",
"settings.proStatus.previewFiveMinutes": "5-minute preview only",
- "settings.proGuide.general": "Only Pro users can use colored containers / colored grids"
+ "settings.proGuide.general": "Only Pro users can use colored containers / colored grids",
+ "pro.feature.customContainerQuota": "Custom container quota",
+ "pro.feature.customContainerQuota.benefit": "Free version can maintain 6 custom containers. Pro unlocks unlimited tag management.",
+ "pro.prompt.customContainerQuota": "Custom container quota is part of Pro. Unlock Pro to create and manage unlimited tags.",
+ "pro.customContainers.quotaShort": "Free users can use 6 custom tags (containers): %used% / %limit% used",
+ "pro.customContainers.quotaUnlimitedShort": "Pro unlocked: unlimited custom containers",
+ "pro.customContainers.quotaPrompt": "Free version can maintain %limit% custom containers. You have used %used% / %limit%, with %remaining% remaining. Upgrade to Pro to create and manage unlimited tags, or edit/delete existing tags to keep using the free quota.",
+ "pro.customContainers.manageExisting": "Manage existing tags",
+ "pro.tagSorting.previewToast": "Current tag order is preview only. Unlock Pro to save tag order.",
+ "pro.feature.maintainableTagCount": "Maintainable tag count",
+ "pro.feature.tagOrderPersistence": "Custom sorting: tag position in the list (= container position)",
+ "pro.feature.containerAppOrderPersistence": "Custom sorting: app icon positions inside containers",
+ "settings.proStatus.maxSixTags": "Up to 6",
+ "settings.proStatus.unlimitedTags": "✅ No limit",
+ "settings.proStatus.previewOnly": "Preview only",
+ "settings.proStatus.supported": "✅ Supported"
}
diff --git a/src/Apptag/Localization/es.json b/src/Apptag/Localization/es.json
index 2ede230..013bd9c 100644
--- a/src/Apptag/Localization/es.json
+++ b/src/Apptag/Localization/es.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "No disponible",
"settings.proStatus.previewFiveMinutes": "Solo vista previa de 5 minutos",
- "settings.proGuide.general": "Solo los usuarios Pro pueden usar contenedores / cuadrículas de color"
+ "settings.proGuide.general": "Solo los usuarios Pro pueden usar contenedores / cuadrículas de color",
+ "pro.feature.customContainerQuota": "Cuota de contenedores personalizados",
+ "pro.feature.customContainerQuota.benefit": "La versión gratuita permite mantener 6 contenedores personalizados. Pro desbloquea gestión ilimitada de etiquetas.",
+ "pro.prompt.customContainerQuota": "La cuota de contenedores personalizados es parte de Pro. Desbloquea Pro para crear y gestionar etiquetas ilimitadas.",
+ "pro.customContainers.quotaShort": "Los usuarios gratis pueden usar 6 etiquetas personalizadas (contenedores): %used% / %limit% usadas",
+ "pro.customContainers.quotaUnlimitedShort": "Pro desbloqueado: contenedores personalizados ilimitados",
+ "pro.customContainers.quotaPrompt": "La versión gratuita permite mantener %limit% contenedores personalizados. Has usado %used% / %limit%, quedan %remaining%. Actualiza a Pro para etiquetas ilimitadas, o edita/elimina etiquetas existentes para seguir usando la cuota gratuita.",
+ "pro.customContainers.manageExisting": "Gestionar etiquetas existentes",
+ "pro.tagSorting.previewToast": "El orden actual de etiquetas es solo una vista previa. Desbloquea Pro para guardarlo.",
+ "pro.feature.maintainableTagCount": "Cantidad de etiquetas mantenibles",
+ "pro.feature.tagOrderPersistence": "Orden personalizado: posición de etiqueta en la lista (= posición del contenedor)",
+ "pro.feature.containerAppOrderPersistence": "Orden personalizado: posiciones de iconos dentro de contenedores",
+ "settings.proStatus.maxSixTags": "Hasta 6",
+ "settings.proStatus.unlimitedTags": "✅ Sin límite",
+ "settings.proStatus.previewOnly": "Solo vista previa",
+ "settings.proStatus.supported": "✅ Compatible"
}
diff --git a/src/Apptag/Localization/fr.json b/src/Apptag/Localization/fr.json
index 65a6ebc..757cd64 100644
--- a/src/Apptag/Localization/fr.json
+++ b/src/Apptag/Localization/fr.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Indisponible",
"settings.proStatus.previewFiveMinutes": "Aperçu de 5 minutes seulement",
- "settings.proGuide.general": "Seuls les utilisateurs Pro peuvent utiliser les conteneurs / grilles colorés"
+ "settings.proGuide.general": "Seuls les utilisateurs Pro peuvent utiliser les conteneurs / grilles colorés",
+ "pro.feature.customContainerQuota": "Quota de conteneurs personnalisés",
+ "pro.feature.customContainerQuota.benefit": "La version gratuite permet de gérer 6 conteneurs personnalisés. Pro déverrouille une gestion illimitée des tags.",
+ "pro.prompt.customContainerQuota": "La limite de conteneurs personnalisés fait partie de Pro. Déverrouillez Pro pour créer et gérer un nombre illimité de tags.",
+ "pro.customContainers.quotaShort": "Les utilisateurs gratuits peuvent utiliser 6 tags personnalisés (conteneurs) : %used% / %limit% utilisés",
+ "pro.customContainers.quotaUnlimitedShort": "Pro déverrouillé : conteneurs personnalisés illimités",
+ "pro.customContainers.quotaPrompt": "La version gratuite permet de gérer %limit% conteneurs personnalisés. Vous avez utilisé %used% / %limit%, il en reste %remaining%. Passez à Pro pour créer et gérer un nombre illimité de tags, ou modifiez/supprimez des tags existants pour continuer avec le quota gratuit.",
+ "pro.customContainers.manageExisting": "Gérer les tags existants",
+ "pro.tagSorting.previewToast": "L’ordre actuel des tags est seulement un aperçu. Déverrouillez Pro pour l’enregistrer.",
+ "pro.feature.maintainableTagCount": "Nombre de tags modifiables",
+ "pro.feature.tagOrderPersistence": "Tri personnalisé : position du tag dans la liste (= position du conteneur)",
+ "pro.feature.containerAppOrderPersistence": "Tri personnalisé : position des icônes dans les conteneurs",
+ "settings.proStatus.maxSixTags": "Jusqu’à 6",
+ "settings.proStatus.unlimitedTags": "✅ Sans limite",
+ "settings.proStatus.previewOnly": "Aperçu seulement",
+ "settings.proStatus.supported": "✅ Pris en charge"
}
diff --git a/src/Apptag/Localization/id.json b/src/Apptag/Localization/id.json
index 0e94429..a510814 100644
--- a/src/Apptag/Localization/id.json
+++ b/src/Apptag/Localization/id.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Tidak tersedia",
"settings.proStatus.previewFiveMinutes": "Hanya pratinjau 5 menit",
- "settings.proGuide.general": "Hanya pengguna Pro yang dapat memakai kontainer / kisi berwarna"
+ "settings.proGuide.general": "Hanya pengguna Pro yang dapat memakai kontainer / kisi berwarna",
+ "pro.feature.customContainerQuota": "Kuota kontainer khusus",
+ "pro.feature.customContainerQuota.benefit": "Versi gratis dapat mengelola 6 kontainer khusus. Pro membuka pengelolaan tag tanpa batas.",
+ "pro.prompt.customContainerQuota": "Kuota kontainer khusus adalah fitur Pro. Buka Pro untuk membuat dan mengelola tag tanpa batas.",
+ "pro.customContainers.quotaShort": "Pengguna gratis dapat memakai 6 tag khusus (kontainer): %used% / %limit% terpakai",
+ "pro.customContainers.quotaUnlimitedShort": "Pro terbuka: kontainer khusus tanpa batas",
+ "pro.customContainers.quotaPrompt": "Versi gratis dapat mengelola %limit% kontainer khusus. Anda memakai %used% / %limit%, tersisa %remaining%. Tingkatkan ke Pro untuk tag tanpa batas, atau edit/hapus tag yang ada untuk tetap memakai kuota gratis.",
+ "pro.customContainers.manageExisting": "Kelola tag yang ada",
+ "pro.tagSorting.previewToast": "Urutan tag saat ini hanya pratinjau. Buka Pro untuk menyimpan urutan tag.",
+ "pro.feature.maintainableTagCount": "Jumlah tag yang dapat dikelola",
+ "pro.feature.tagOrderPersistence": "Pengurutan khusus: posisi tag di daftar (= posisi kontainer)",
+ "pro.feature.containerAppOrderPersistence": "Pengurutan khusus: posisi ikon aplikasi di dalam kontainer",
+ "settings.proStatus.maxSixTags": "Hingga 6",
+ "settings.proStatus.unlimitedTags": "✅ Tanpa batas",
+ "settings.proStatus.previewOnly": "Pratinjau saja",
+ "settings.proStatus.supported": "✅ Didukung"
}
diff --git a/src/Apptag/Localization/it.json b/src/Apptag/Localization/it.json
index dff1432..6399d25 100644
--- a/src/Apptag/Localization/it.json
+++ b/src/Apptag/Localization/it.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Non disponibile",
"settings.proStatus.previewFiveMinutes": "Solo anteprima di 5 minuti",
- "settings.proGuide.general": "Solo gli utenti Pro possono usare contenitori / griglie colorati"
+ "settings.proGuide.general": "Solo gli utenti Pro possono usare contenitori / griglie colorati",
+ "pro.feature.customContainerQuota": "Quota contenitori personalizzati",
+ "pro.feature.customContainerQuota.benefit": "La versione gratuita permette di gestire 6 contenitori personalizzati. Pro sblocca la gestione illimitata dei tag.",
+ "pro.prompt.customContainerQuota": "La quota dei contenitori personalizzati è una funzione Pro. Sblocca Pro per creare e gestire tag illimitati.",
+ "pro.customContainers.quotaShort": "Gli utenti gratuiti possono usare 6 tag personalizzati (contenitori): %used% / %limit% usati",
+ "pro.customContainers.quotaUnlimitedShort": "Pro sbloccato: contenitori personalizzati illimitati",
+ "pro.customContainers.quotaPrompt": "La versione gratuita permette di gestire %limit% contenitori personalizzati. Hai usato %used% / %limit%, ne restano %remaining%. Passa a Pro per tag illimitati, oppure modifica/elimina tag esistenti per continuare con la quota gratuita.",
+ "pro.customContainers.manageExisting": "Gestisci tag esistenti",
+ "pro.tagSorting.previewToast": "L’ordine attuale dei tag è solo un’anteprima. Sblocca Pro per salvarlo.",
+ "pro.feature.maintainableTagCount": "Numero di tag gestibili",
+ "pro.feature.tagOrderPersistence": "Ordinamento personalizzato: posizione del tag nell’elenco (= posizione contenitore)",
+ "pro.feature.containerAppOrderPersistence": "Ordinamento personalizzato: posizioni icone app nei contenitori",
+ "settings.proStatus.maxSixTags": "Fino a 6",
+ "settings.proStatus.unlimitedTags": "✅ Nessun limite",
+ "settings.proStatus.previewOnly": "Solo anteprima",
+ "settings.proStatus.supported": "✅ Supportato"
}
diff --git a/src/Apptag/Localization/ja.json b/src/Apptag/Localization/ja.json
index cf6d231..ee5e996 100644
--- a/src/Apptag/Localization/ja.json
+++ b/src/Apptag/Localization/ja.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro 版",
"settings.proStatus.unavailableFeature": "利用不可",
"settings.proStatus.previewFiveMinutes": "5分間だけプレビュー",
- "settings.proGuide.general": "カラーコンテナ / カラーグリッドは Pro ユーザーのみ利用できます"
+ "settings.proGuide.general": "カラーコンテナ / カラーグリッドは Pro ユーザーのみ利用できます",
+ "pro.feature.customContainerQuota": "カスタムコンテナ枠",
+ "pro.feature.customContainerQuota.benefit": "無料版では 6 個のカスタムコンテナを管理できます。Pro ではタグ管理が無制限になります。",
+ "pro.prompt.customContainerQuota": "カスタムコンテナ枠は Pro 機能です。Pro を解除すると、タグを無制限に作成・管理できます。",
+ "pro.customContainers.quotaShort": "無料ユーザーは6個のカスタムタグ(コンテナ)を使用できます: %used% / %limit% 使用済み",
+ "pro.customContainers.quotaUnlimitedShort": "Pro 解除済み: カスタムコンテナ無制限",
+ "pro.customContainers.quotaPrompt": "無料版では %limit% 個のカスタムコンテナを管理できます。現在 %used% / %limit% 使用、残り %remaining% 個です。Pro にアップグレードするとタグを無制限に作成・管理できます。既存タグを編集または削除して無料枠を空けることもできます。",
+ "pro.customContainers.manageExisting": "既存タグを管理",
+ "pro.tagSorting.previewToast": "現在のタグ順はプレビューのみです。Pro を解除するとタグ順を保存できます。",
+ "pro.feature.maintainableTagCount": "管理可能なタグ数",
+ "pro.feature.tagOrderPersistence": "カスタム並べ替え: リスト内のタグ位置(= コンテナ表示位置)",
+ "pro.feature.containerAppOrderPersistence": "カスタム並べ替え: コンテナ内のアプリアイコン位置",
+ "settings.proStatus.maxSixTags": "最大 6 個",
+ "settings.proStatus.unlimitedTags": "✅制限なし",
+ "settings.proStatus.previewOnly": "プレビューのみ",
+ "settings.proStatus.supported": "✅ 対応"
}
diff --git a/src/Apptag/Localization/ko.json b/src/Apptag/Localization/ko.json
index 6a2afe0..7b67c10 100644
--- a/src/Apptag/Localization/ko.json
+++ b/src/Apptag/Localization/ko.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "사용 불가",
"settings.proStatus.previewFiveMinutes": "5분 미리보기만 가능",
- "settings.proGuide.general": "컬러 컨테이너 / 컬러 그리드는 Pro 사용자만 사용할 수 있습니다"
+ "settings.proGuide.general": "컬러 컨테이너 / 컬러 그리드는 Pro 사용자만 사용할 수 있습니다",
+ "pro.feature.customContainerQuota": "사용자 컨테이너 한도",
+ "pro.feature.customContainerQuota.benefit": "무료 버전은 사용자 컨테이너 6개를 관리할 수 있습니다. Pro는 태그 관리를 무제한으로 제공합니다.",
+ "pro.prompt.customContainerQuota": "사용자 컨테이너 한도는 Pro 기능입니다. Pro를 잠금 해제하면 태그를 무제한으로 만들고 관리할 수 있습니다.",
+ "pro.customContainers.quotaShort": "무료 사용자는 사용자 지정 태그(컨테이너) 6개를 사용할 수 있습니다: %used% / %limit% 사용됨",
+ "pro.customContainers.quotaUnlimitedShort": "Pro 잠금 해제됨: 사용자 컨테이너 무제한",
+ "pro.customContainers.quotaPrompt": "무료 버전은 사용자 컨테이너 %limit%개를 관리할 수 있습니다. 현재 %used% / %limit%개 사용, 남은 수는 %remaining%개입니다. Pro로 업그레이드하면 태그를 무제한으로 만들고 관리할 수 있습니다. 기존 태그를 편집하거나 삭제해 무료 한도를 계속 사용할 수도 있습니다.",
+ "pro.customContainers.manageExisting": "기존 태그 관리",
+ "pro.tagSorting.previewToast": "현재 태그 순서는 미리보기만 가능합니다. Pro를 잠금 해제하면 태그 순서를 저장할 수 있습니다.",
+ "pro.feature.maintainableTagCount": "관리 가능한 태그 수",
+ "pro.feature.tagOrderPersistence": "사용자 정렬: 목록의 태그 위치(= 컨테이너 위치)",
+ "pro.feature.containerAppOrderPersistence": "사용자 정렬: 컨테이너 안 앱 아이콘 위치",
+ "settings.proStatus.maxSixTags": "최대 6개",
+ "settings.proStatus.unlimitedTags": "✅ 제한 없음",
+ "settings.proStatus.previewOnly": "미리보기만",
+ "settings.proStatus.supported": "✅ 지원"
}
diff --git a/src/Apptag/Localization/ms.json b/src/Apptag/Localization/ms.json
index 97c9efe..b16ac1b 100644
--- a/src/Apptag/Localization/ms.json
+++ b/src/Apptag/Localization/ms.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Tidak tersedia",
"settings.proStatus.previewFiveMinutes": "Pratonton 5 minit sahaja",
- "settings.proGuide.general": "Hanya pengguna Pro boleh menggunakan bekas / grid berwarna"
+ "settings.proGuide.general": "Hanya pengguna Pro boleh menggunakan bekas / grid berwarna",
+ "pro.feature.customContainerQuota": "Kuota bekas tersuai",
+ "pro.feature.customContainerQuota.benefit": "Versi percuma boleh mengurus 6 bekas tersuai. Pro membuka pengurusan tag tanpa had.",
+ "pro.prompt.customContainerQuota": "Kuota bekas tersuai ialah ciri Pro. Buka Pro untuk mencipta dan mengurus tag tanpa had.",
+ "pro.customContainers.quotaShort": "Pengguna percuma boleh menggunakan 6 tag tersuai (bekas): %used% / %limit% digunakan",
+ "pro.customContainers.quotaUnlimitedShort": "Pro dibuka: bekas tersuai tanpa had",
+ "pro.customContainers.quotaPrompt": "Versi percuma boleh mengurus %limit% bekas tersuai. Anda telah menggunakan %used% / %limit%, baki %remaining%. Naik taraf ke Pro untuk tag tanpa had, atau edit/padam tag sedia ada untuk terus menggunakan kuota percuma.",
+ "pro.customContainers.manageExisting": "Urus tag sedia ada",
+ "pro.tagSorting.previewToast": "Susunan tag semasa hanya pratonton. Buka Pro untuk menyimpan susunan tag.",
+ "pro.feature.maintainableTagCount": "Bilangan tag boleh diurus",
+ "pro.feature.tagOrderPersistence": "Susunan tersuai: kedudukan tag dalam senarai (= kedudukan bekas)",
+ "pro.feature.containerAppOrderPersistence": "Susunan tersuai: kedudukan ikon aplikasi dalam bekas",
+ "settings.proStatus.maxSixTags": "Sehingga 6",
+ "settings.proStatus.unlimitedTags": "✅ Tiada had",
+ "settings.proStatus.previewOnly": "Pratonton sahaja",
+ "settings.proStatus.supported": "✅ Disokong"
}
diff --git a/src/Apptag/Localization/nb.json b/src/Apptag/Localization/nb.json
index 86193a4..4b53453 100644
--- a/src/Apptag/Localization/nb.json
+++ b/src/Apptag/Localization/nb.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Ikke tilgjengelig",
"settings.proStatus.previewFiveMinutes": "Kun 5 minutters forhåndsvisning",
- "settings.proGuide.general": "Bare Pro-brukere kan bruke fargede beholdere / fargede rutenett"
+ "settings.proGuide.general": "Bare Pro-brukere kan bruke fargede beholdere / fargede rutenett",
+ "pro.feature.customContainerQuota": "Kvote for egne beholdere",
+ "pro.feature.customContainerQuota.benefit": "Gratisversjonen kan vedlikeholde 6 egne beholdere. Pro låser opp ubegrenset taggstyring.",
+ "pro.prompt.customContainerQuota": "Kvote for egne beholdere er en Pro-funksjon. Lås opp Pro for å opprette og administrere ubegrensede tagger.",
+ "pro.customContainers.quotaShort": "Gratisbrukere kan bruke 6 egne tagger (beholdere): %used% / %limit% brukt",
+ "pro.customContainers.quotaUnlimitedShort": "Pro låst opp: ubegrensede egne beholdere",
+ "pro.customContainers.quotaPrompt": "Gratisversjonen kan vedlikeholde %limit% egne beholdere. Du har brukt %used% / %limit%, %remaining% gjenstår. Oppgrader til Pro for ubegrensede tagger, eller rediger/slett eksisterende tagger for å fortsette med gratiskvoten.",
+ "pro.customContainers.manageExisting": "Administrer eksisterende tagger",
+ "pro.tagSorting.previewToast": "Gjeldende taggrekkefølge er bare forhåndsvisning. Lås opp Pro for å lagre den.",
+ "pro.feature.maintainableTagCount": "Antall tagger som kan vedlikeholdes",
+ "pro.feature.tagOrderPersistence": "Egen sortering: taggposisjon i listen (= beholderposisjon)",
+ "pro.feature.containerAppOrderPersistence": "Egen sortering: appikonposisjoner i beholdere",
+ "settings.proStatus.maxSixTags": "Opptil 6",
+ "settings.proStatus.unlimitedTags": "✅ Ingen grense",
+ "settings.proStatus.previewOnly": "Kun forhåndsvisning",
+ "settings.proStatus.supported": "✅ Støttes"
}
diff --git a/src/Apptag/Localization/nl.json b/src/Apptag/Localization/nl.json
index 34f4196..9bc018d 100644
--- a/src/Apptag/Localization/nl.json
+++ b/src/Apptag/Localization/nl.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Niet beschikbaar",
"settings.proStatus.previewFiveMinutes": "Alleen 5 minuten voorbeeld",
- "settings.proGuide.general": "Alleen Pro-gebruikers kunnen gekleurde containers / rasters gebruiken"
+ "settings.proGuide.general": "Alleen Pro-gebruikers kunnen gekleurde containers / rasters gebruiken",
+ "pro.feature.customContainerQuota": "Limiet voor aangepaste containers",
+ "pro.feature.customContainerQuota.benefit": "De gratis versie kan 6 aangepaste containers beheren. Pro ontgrendelt onbeperkt tagbeheer.",
+ "pro.prompt.customContainerQuota": "Het limiet voor aangepaste containers is een Pro-functie. Ontgrendel Pro om onbeperkt tags te maken en beheren.",
+ "pro.customContainers.quotaShort": "Gratis gebruikers kunnen 6 aangepaste tags (containers) gebruiken: %used% / %limit% gebruikt",
+ "pro.customContainers.quotaUnlimitedShort": "Pro ontgrendeld: onbeperkte aangepaste containers",
+ "pro.customContainers.quotaPrompt": "De gratis versie kan %limit% aangepaste containers beheren. Je hebt %used% / %limit% gebruikt, %remaining% over. Upgrade naar Pro voor onbeperkte tags, of bewerk/verwijder bestaande tags om de gratis limiet te blijven gebruiken.",
+ "pro.customContainers.manageExisting": "Bestaande tags beheren",
+ "pro.tagSorting.previewToast": "De huidige tagvolgorde is alleen een voorbeeld. Ontgrendel Pro om deze op te slaan.",
+ "pro.feature.maintainableTagCount": "Aantal beheerbare tags",
+ "pro.feature.tagOrderPersistence": "Aangepaste sortering: tagpositie in de lijst (= containerpositie)",
+ "pro.feature.containerAppOrderPersistence": "Aangepaste sortering: app-icoonposities in containers",
+ "settings.proStatus.maxSixTags": "Tot 6",
+ "settings.proStatus.unlimitedTags": "✅ Geen limiet",
+ "settings.proStatus.previewOnly": "Alleen voorbeeld",
+ "settings.proStatus.supported": "✅ Ondersteund"
}
diff --git a/src/Apptag/Localization/nn.json b/src/Apptag/Localization/nn.json
index 1bba986..990634f 100644
--- a/src/Apptag/Localization/nn.json
+++ b/src/Apptag/Localization/nn.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Ikkje tilgjengeleg",
"settings.proStatus.previewFiveMinutes": "Berre 5 minutt førehandsvising",
- "settings.proGuide.general": "Berre Pro-brukarar kan bruke farga behaldarar / farga rutenett"
+ "settings.proGuide.general": "Berre Pro-brukarar kan bruke farga behaldarar / farga rutenett",
+ "pro.feature.customContainerQuota": "Kvote for eigne behaldarar",
+ "pro.feature.customContainerQuota.benefit": "Gratisversjonen kan vedlikehalde 6 eigne behaldarar. Pro låser opp uavgrensa merkelappstyring.",
+ "pro.prompt.customContainerQuota": "Kvote for eigne behaldarar er ein Pro-funksjon. Lås opp Pro for å opprette og administrere uavgrensa merkelappar.",
+ "pro.customContainers.quotaShort": "Gratisbrukarar kan bruke 6 eigne taggar (behaldarar): %used% / %limit% brukt",
+ "pro.customContainers.quotaUnlimitedShort": "Pro låst opp: uavgrensa eigne behaldarar",
+ "pro.customContainers.quotaPrompt": "Gratisversjonen kan vedlikehalde %limit% eigne behaldarar. Du har brukt %used% / %limit%, %remaining% står att. Oppgrader til Pro for uavgrensa merkelappar, eller rediger/slett eksisterande merkelappar for å halde fram med gratiskvoten.",
+ "pro.customContainers.manageExisting": "Administrer eksisterande merkelappar",
+ "pro.tagSorting.previewToast": "Gjeldande merkelapprekkjefølgje er berre førehandsvising. Lås opp Pro for å lagre henne.",
+ "pro.feature.maintainableTagCount": "Tal på merkelappar som kan vedlikehaldast",
+ "pro.feature.tagOrderPersistence": "Eiga sortering: merkelapposisjon i lista (= behaldarposisjon)",
+ "pro.feature.containerAppOrderPersistence": "Eiga sortering: appikonposisjonar i behaldarar",
+ "settings.proStatus.maxSixTags": "Opptil 6",
+ "settings.proStatus.unlimitedTags": "✅ Inga grense",
+ "settings.proStatus.previewOnly": "Berre førehandsvising",
+ "settings.proStatus.supported": "✅ Støtta"
}
diff --git a/src/Apptag/Localization/no.json b/src/Apptag/Localization/no.json
index 86193a4..4b53453 100644
--- a/src/Apptag/Localization/no.json
+++ b/src/Apptag/Localization/no.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Ikke tilgjengelig",
"settings.proStatus.previewFiveMinutes": "Kun 5 minutters forhåndsvisning",
- "settings.proGuide.general": "Bare Pro-brukere kan bruke fargede beholdere / fargede rutenett"
+ "settings.proGuide.general": "Bare Pro-brukere kan bruke fargede beholdere / fargede rutenett",
+ "pro.feature.customContainerQuota": "Kvote for egne beholdere",
+ "pro.feature.customContainerQuota.benefit": "Gratisversjonen kan vedlikeholde 6 egne beholdere. Pro låser opp ubegrenset taggstyring.",
+ "pro.prompt.customContainerQuota": "Kvote for egne beholdere er en Pro-funksjon. Lås opp Pro for å opprette og administrere ubegrensede tagger.",
+ "pro.customContainers.quotaShort": "Gratisbrukere kan bruke 6 egne tagger (beholdere): %used% / %limit% brukt",
+ "pro.customContainers.quotaUnlimitedShort": "Pro låst opp: ubegrensede egne beholdere",
+ "pro.customContainers.quotaPrompt": "Gratisversjonen kan vedlikeholde %limit% egne beholdere. Du har brukt %used% / %limit%, %remaining% gjenstår. Oppgrader til Pro for ubegrensede tagger, eller rediger/slett eksisterende tagger for å fortsette med gratiskvoten.",
+ "pro.customContainers.manageExisting": "Administrer eksisterende tagger",
+ "pro.tagSorting.previewToast": "Gjeldende taggrekkefølge er bare forhåndsvisning. Lås opp Pro for å lagre den.",
+ "pro.feature.maintainableTagCount": "Antall tagger som kan vedlikeholdes",
+ "pro.feature.tagOrderPersistence": "Egen sortering: taggposisjon i listen (= beholderposisjon)",
+ "pro.feature.containerAppOrderPersistence": "Egen sortering: appikonposisjoner i beholdere",
+ "settings.proStatus.maxSixTags": "Opptil 6",
+ "settings.proStatus.unlimitedTags": "✅ Ingen grense",
+ "settings.proStatus.previewOnly": "Kun forhåndsvisning",
+ "settings.proStatus.supported": "✅ Støttes"
}
diff --git a/src/Apptag/Localization/pl.json b/src/Apptag/Localization/pl.json
index 5627ba8..6b44813 100644
--- a/src/Apptag/Localization/pl.json
+++ b/src/Apptag/Localization/pl.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Niedostępne",
"settings.proStatus.previewFiveMinutes": "Tylko 5 minut podglądu",
- "settings.proGuide.general": "Tylko użytkownicy Pro mogą używać kolorowych kontenerów / siatek"
+ "settings.proGuide.general": "Tylko użytkownicy Pro mogą używać kolorowych kontenerów / siatek",
+ "pro.feature.customContainerQuota": "Limit własnych kontenerów",
+ "pro.feature.customContainerQuota.benefit": "Wersja bezpłatna pozwala utrzymywać 6 własnych kontenerów. Pro odblokowuje nieograniczone zarządzanie tagami.",
+ "pro.prompt.customContainerQuota": "Limit własnych kontenerów to funkcja Pro. Odblokuj Pro, aby tworzyć i zarządzać tagami bez limitu.",
+ "pro.customContainers.quotaShort": "Użytkownicy wersji darmowej mogą używać 6 własnych tagów (kontenerów): użyto %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "Pro odblokowane: własne kontenery bez limitu",
+ "pro.customContainers.quotaPrompt": "Wersja bezpłatna pozwala utrzymywać %limit% własnych kontenerów. Użyto %used% / %limit%, pozostało %remaining%. Przejdź na Pro, aby mieć tagi bez limitu, albo edytuj/usuń istniejące tagi, aby dalej korzystać z darmowego limitu.",
+ "pro.customContainers.manageExisting": "Zarządzaj istniejącymi tagami",
+ "pro.tagSorting.previewToast": "Bieżąca kolejność tagów jest tylko podglądem. Odblokuj Pro, aby ją zapisać.",
+ "pro.feature.maintainableTagCount": "Liczba tagów do utrzymania",
+ "pro.feature.tagOrderPersistence": "Sortowanie własne: pozycja tagu na liście (= pozycja kontenera)",
+ "pro.feature.containerAppOrderPersistence": "Sortowanie własne: pozycje ikon aplikacji w kontenerach",
+ "settings.proStatus.maxSixTags": "Do 6",
+ "settings.proStatus.unlimitedTags": "✅ Bez limitu",
+ "settings.proStatus.previewOnly": "Tylko podgląd",
+ "settings.proStatus.supported": "✅ Obsługiwane"
}
diff --git a/src/Apptag/Localization/pt-BR.json b/src/Apptag/Localization/pt-BR.json
index 7f08756..7fff595 100644
--- a/src/Apptag/Localization/pt-BR.json
+++ b/src/Apptag/Localization/pt-BR.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Indisponível",
"settings.proStatus.previewFiveMinutes": "Apenas prévia de 5 minutos",
- "settings.proGuide.general": "Só usuários Pro podem usar contêineres / grades coloridos"
+ "settings.proGuide.general": "Só usuários Pro podem usar contêineres / grades coloridos",
+ "pro.feature.customContainerQuota": "Cota de contêineres personalizados",
+ "pro.feature.customContainerQuota.benefit": "A versão gratuita permite manter 6 contêineres personalizados. O Pro libera gerenciamento ilimitado de tags.",
+ "pro.prompt.customContainerQuota": "A cota de contêineres personalizados faz parte do Pro. Desbloqueie o Pro para criar e gerenciar tags ilimitadas.",
+ "pro.customContainers.quotaShort": "Usuários grátis podem usar 6 tags personalizadas (contêineres): %used% / %limit% usadas",
+ "pro.customContainers.quotaUnlimitedShort": "Pro desbloqueado: contêineres personalizados ilimitados",
+ "pro.customContainers.quotaPrompt": "A versão gratuita permite manter %limit% contêineres personalizados. Você usou %used% / %limit%, restam %remaining%. Atualize para o Pro para tags ilimitadas, ou edite/exclua tags existentes para continuar usando a cota gratuita.",
+ "pro.customContainers.manageExisting": "Gerenciar tags existentes",
+ "pro.tagSorting.previewToast": "A ordem atual das tags é apenas uma prévia. Desbloqueie o Pro para salvar.",
+ "pro.feature.maintainableTagCount": "Quantidade de tags editáveis",
+ "pro.feature.tagOrderPersistence": "Ordenação personalizada: posição da tag na lista (= posição do contêiner)",
+ "pro.feature.containerAppOrderPersistence": "Ordenação personalizada: posições dos ícones dentro dos contêineres",
+ "settings.proStatus.maxSixTags": "Até 6",
+ "settings.proStatus.unlimitedTags": "✅ Sem limite",
+ "settings.proStatus.previewOnly": "Só prévia",
+ "settings.proStatus.supported": "✅ Compatível"
}
diff --git a/src/Apptag/Localization/ro.json b/src/Apptag/Localization/ro.json
index cf195bb..c324790 100644
--- a/src/Apptag/Localization/ro.json
+++ b/src/Apptag/Localization/ro.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Indisponibil",
"settings.proStatus.previewFiveMinutes": "Doar previzualizare de 5 minute",
- "settings.proGuide.general": "Doar utilizatorii Pro pot folosi containere / grile colorate"
+ "settings.proGuide.general": "Doar utilizatorii Pro pot folosi containere / grile colorate",
+ "pro.feature.customContainerQuota": "Cotă pentru containere personalizate",
+ "pro.feature.customContainerQuota.benefit": "Versiunea gratuită poate gestiona 6 containere personalizate. Pro deblochează gestionarea nelimitată a etichetelor.",
+ "pro.prompt.customContainerQuota": "Cota pentru containere personalizate este o funcție Pro. Deblochează Pro pentru a crea și gestiona etichete nelimitate.",
+ "pro.customContainers.quotaShort": "Utilizatorii versiunii gratuite pot folosi 6 etichete personalizate (containere): %used% / %limit% folosite",
+ "pro.customContainers.quotaUnlimitedShort": "Pro deblocat: containere personalizate nelimitate",
+ "pro.customContainers.quotaPrompt": "Versiunea gratuită poate gestiona %limit% containere personalizate. Ai folosit %used% / %limit%, mai rămân %remaining%. Treci la Pro pentru etichete nelimitate sau editează/șterge etichete existente pentru a continua cu cota gratuită.",
+ "pro.customContainers.manageExisting": "Gestionează etichetele existente",
+ "pro.tagSorting.previewToast": "Ordinea actuală a etichetelor este doar previzualizare. Deblochează Pro pentru a o salva.",
+ "pro.feature.maintainableTagCount": "Număr de etichete gestionabile",
+ "pro.feature.tagOrderPersistence": "Sortare personalizată: poziția etichetei în listă (= poziția containerului)",
+ "pro.feature.containerAppOrderPersistence": "Sortare personalizată: pozițiile pictogramelor aplicațiilor în containere",
+ "settings.proStatus.maxSixTags": "Până la 6",
+ "settings.proStatus.unlimitedTags": "✅ Fără limită",
+ "settings.proStatus.previewOnly": "Doar previzualizare",
+ "settings.proStatus.supported": "✅ Acceptat"
}
diff --git a/src/Apptag/Localization/ru.json b/src/Apptag/Localization/ru.json
index de52125..941c049 100644
--- a/src/Apptag/Localization/ru.json
+++ b/src/Apptag/Localization/ru.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Недоступно",
"settings.proStatus.previewFiveMinutes": "Только 5 минут предпросмотра",
- "settings.proGuide.general": "Цветные контейнеры / сетки доступны только пользователям Pro"
+ "settings.proGuide.general": "Цветные контейнеры / сетки доступны только пользователям Pro",
+ "pro.feature.customContainerQuota": "Лимит пользовательских контейнеров",
+ "pro.feature.customContainerQuota.benefit": "В бесплатной версии можно вести 6 пользовательских контейнеров. Pro снимает ограничение на управление тегами.",
+ "pro.prompt.customContainerQuota": "Лимит пользовательских контейнеров — функция Pro. Разблокируйте Pro, чтобы создавать и управлять тегами без ограничений.",
+ "pro.customContainers.quotaShort": "Бесплатные пользователи могут использовать 6 пользовательских тегов (контейнеров): использовано %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "Pro разблокирован: пользовательские контейнеры без лимита",
+ "pro.customContainers.quotaPrompt": "В бесплатной версии можно вести %limit% пользовательских контейнеров. Использовано %used% / %limit%, осталось %remaining%. Перейдите на Pro для неограниченных тегов или измените/удалите существующие теги, чтобы освободить лимит.",
+ "pro.customContainers.manageExisting": "Управлять тегами",
+ "pro.tagSorting.previewToast": "Текущий порядок тегов — только предпросмотр. Разблокируйте Pro, чтобы сохранить его.",
+ "pro.feature.maintainableTagCount": "Количество редактируемых тегов",
+ "pro.feature.tagOrderPersistence": "Своя сортировка: позиция тега в списке (= позиция контейнера)",
+ "pro.feature.containerAppOrderPersistence": "Своя сортировка: позиции значков приложений в контейнерах",
+ "settings.proStatus.maxSixTags": "До 6",
+ "settings.proStatus.unlimitedTags": "✅ Без лимита",
+ "settings.proStatus.previewOnly": "Только предпросмотр",
+ "settings.proStatus.supported": "✅ Поддерживается"
}
diff --git a/src/Apptag/Localization/sr-Cyrl.json b/src/Apptag/Localization/sr-Cyrl.json
index 342612f..820e8ae 100644
--- a/src/Apptag/Localization/sr-Cyrl.json
+++ b/src/Apptag/Localization/sr-Cyrl.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Недоступно",
"settings.proStatus.previewFiveMinutes": "Само 5 минута прегледа",
- "settings.proGuide.general": "Само Pro корисници могу да користе обојене контејнере / мреже"
+ "settings.proGuide.general": "Само Pro корисници могу да користе обојене контејнере / мреже",
+ "pro.feature.customContainerQuota": "Ограничење прилагођених контејнера",
+ "pro.feature.customContainerQuota.benefit": "Бесплатна верзија може да одржава 6 прилагођених контејнера. Pro откључава неограничено управљање ознакама.",
+ "pro.prompt.customContainerQuota": "Ограничење прилагођених контејнера је Pro функција. Откључајте Pro за неограничено креирање и управљање ознакама.",
+ "pro.customContainers.quotaShort": "Бесплатни корисници могу да користе 6 прилагођених ознака (контејнера): искоришћено %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "Pro откључан: неограничени прилагођени контејнери",
+ "pro.customContainers.quotaPrompt": "Бесплатна верзија може да одржава %limit% прилагођених контејнера. Искористили сте %used% / %limit%, преостало је %remaining%. Надоградите на Pro за неограничене ознаке или уредите/обришите постојеће ознаке да наставите са бесплатним ограничењем.",
+ "pro.customContainers.manageExisting": "Управљај постојећим ознакама",
+ "pro.tagSorting.previewToast": "Тренутни редослед ознака је само преглед. Откључајте Pro да га сачувате.",
+ "pro.feature.maintainableTagCount": "Број ознака за одржавање",
+ "pro.feature.tagOrderPersistence": "Прилагођено сортирање: позиција ознаке на листи (= позиција контејнера)",
+ "pro.feature.containerAppOrderPersistence": "Прилагођено сортирање: позиције икона апликација у контејнерима",
+ "settings.proStatus.maxSixTags": "До 6",
+ "settings.proStatus.unlimitedTags": "✅ Без ограничења",
+ "settings.proStatus.previewOnly": "Само преглед",
+ "settings.proStatus.supported": "✅ Подржано"
}
diff --git a/src/Apptag/Localization/sv.json b/src/Apptag/Localization/sv.json
index f6ac8b6..5d46075 100644
--- a/src/Apptag/Localization/sv.json
+++ b/src/Apptag/Localization/sv.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Inte tillgängligt",
"settings.proStatus.previewFiveMinutes": "Endast 5 minuters förhandsvisning",
- "settings.proGuide.general": "Endast Pro-användare kan använda färgade behållare / rutnät"
+ "settings.proGuide.general": "Endast Pro-användare kan använda färgade behållare / rutnät",
+ "pro.feature.customContainerQuota": "Kvot för egna behållare",
+ "pro.feature.customContainerQuota.benefit": "Gratisversionen kan hantera 6 egna behållare. Pro låser upp obegränsad tagghantering.",
+ "pro.prompt.customContainerQuota": "Kvoten för egna behållare är en Pro-funktion. Lås upp Pro för att skapa och hantera obegränsat antal taggar.",
+ "pro.customContainers.quotaShort": "Gratisanvändare kan använda 6 egna taggar (behållare): %used% / %limit% använda",
+ "pro.customContainers.quotaUnlimitedShort": "Pro upplåst: obegränsade egna behållare",
+ "pro.customContainers.quotaPrompt": "Gratisversionen kan hantera %limit% egna behållare. Du har använt %used% / %limit%, %remaining% återstår. Uppgradera till Pro för obegränsade taggar, eller redigera/ta bort befintliga taggar för att fortsätta med gratiskvoten.",
+ "pro.customContainers.manageExisting": "Hantera befintliga taggar",
+ "pro.tagSorting.previewToast": "Den aktuella taggordningen är bara en förhandsvisning. Lås upp Pro för att spara den.",
+ "pro.feature.maintainableTagCount": "Antal taggar som kan hanteras",
+ "pro.feature.tagOrderPersistence": "Egen sortering: taggposition i listan (= behållarposition)",
+ "pro.feature.containerAppOrderPersistence": "Egen sortering: appikonernas positioner i behållare",
+ "settings.proStatus.maxSixTags": "Upp till 6",
+ "settings.proStatus.unlimitedTags": "✅ Ingen gräns",
+ "settings.proStatus.previewOnly": "Endast förhandsvisning",
+ "settings.proStatus.supported": "✅ Stöds"
}
diff --git a/src/Apptag/Localization/th.json b/src/Apptag/Localization/th.json
index e8160b2..8265015 100644
--- a/src/Apptag/Localization/th.json
+++ b/src/Apptag/Localization/th.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "ใช้งานไม่ได้",
"settings.proStatus.previewFiveMinutes": "ทดลองใช้ฟรีได้เพียง 5 นาที",
- "settings.proGuide.general": "เฉพาะผู้ใช้ Pro เท่านั้นที่ใช้คอนเทนเนอร์ / กริดสีได้"
+ "settings.proGuide.general": "เฉพาะผู้ใช้ Pro เท่านั้นที่ใช้คอนเทนเนอร์ / กริดสีได้",
+ "pro.feature.customContainerQuota": "โควตาคอนเทนเนอร์กำหนดเอง",
+ "pro.feature.customContainerQuota.benefit": "เวอร์ชันฟรีจัดการคอนเทนเนอร์กำหนดเองได้ 6 รายการ Pro ปลดล็อกการจัดการแท็กแบบไม่จำกัด",
+ "pro.prompt.customContainerQuota": "โควตาคอนเทนเนอร์กำหนดเองเป็นฟีเจอร์ Pro ปลดล็อก Pro เพื่อสร้างและจัดการแท็กได้ไม่จำกัด",
+ "pro.customContainers.quotaShort": "ผู้ใช้ฟรีสามารถใช้แท็กกำหนดเอง 6 แท็ก (คอนเทนเนอร์): ใช้แล้ว %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "ปลดล็อก Pro แล้ว: คอนเทนเนอร์กำหนดเองไม่จำกัด",
+ "pro.customContainers.quotaPrompt": "เวอร์ชันฟรีจัดการคอนเทนเนอร์กำหนดเองได้ %limit% รายการ คุณใช้ไปแล้ว %used% / %limit% เหลือ %remaining% อัปเกรดเป็น Pro เพื่อจัดการแท็กไม่จำกัด หรือแก้ไข/ลบแท็กเดิมเพื่อใช้โควตาฟรีต่อ",
+ "pro.customContainers.manageExisting": "จัดการแท็กที่มีอยู่",
+ "pro.tagSorting.previewToast": "ลำดับแท็กปัจจุบันเป็นเพียงตัวอย่าง ปลดล็อก Pro เพื่อบันทึกลำดับแท็ก",
+ "pro.feature.maintainableTagCount": "จำนวนแท็กที่จัดการได้",
+ "pro.feature.tagOrderPersistence": "การเรียงเอง: ตำแหน่งแท็กในรายการ (= ตำแหน่งคอนเทนเนอร์)",
+ "pro.feature.containerAppOrderPersistence": "การเรียงเอง: ตำแหน่งไอคอนแอปในคอนเทนเนอร์",
+ "settings.proStatus.maxSixTags": "สูงสุด 6",
+ "settings.proStatus.unlimitedTags": "✅ ไม่จำกัด",
+ "settings.proStatus.previewOnly": "ดูตัวอย่างเท่านั้น",
+ "settings.proStatus.supported": "✅ รองรับ"
}
diff --git a/src/Apptag/Localization/tr.json b/src/Apptag/Localization/tr.json
index d15fa2c..8364404 100644
--- a/src/Apptag/Localization/tr.json
+++ b/src/Apptag/Localization/tr.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Kullanılamaz",
"settings.proStatus.previewFiveMinutes": "Yalnızca 5 dakikalık önizleme",
- "settings.proGuide.general": "Renkli kapsayıcılar / ızgaralar yalnızca Pro kullanıcıları içindir"
+ "settings.proGuide.general": "Renkli kapsayıcılar / ızgaralar yalnızca Pro kullanıcıları içindir",
+ "pro.feature.customContainerQuota": "Özel konteyner kotası",
+ "pro.feature.customContainerQuota.benefit": "Ücretsiz sürüm 6 özel konteyneri yönetebilir. Pro sınırsız etiket yönetimini açar.",
+ "pro.prompt.customContainerQuota": "Özel konteyner kotası Pro özelliğidir. Sınırsız etiket oluşturmak ve yönetmek için Pro kilidini açın.",
+ "pro.customContainers.quotaShort": "Ücretsiz kullanıcılar 6 özel etiketi (konteyneri) kullanabilir: %used% / %limit% kullanıldı",
+ "pro.customContainers.quotaUnlimitedShort": "Pro açık: sınırsız özel konteyner",
+ "pro.customContainers.quotaPrompt": "Ücretsiz sürüm %limit% özel konteyneri yönetebilir. %used% / %limit% kullandınız, %remaining% kaldı. Sınırsız etiket için Pro’ya yükseltin veya ücretsiz kotayı kullanmaya devam etmek için mevcut etiketleri düzenleyin/silin.",
+ "pro.customContainers.manageExisting": "Mevcut etiketleri yönet",
+ "pro.tagSorting.previewToast": "Geçerli etiket sırası yalnızca önizlemedir. Kaydetmek için Pro kilidini açın.",
+ "pro.feature.maintainableTagCount": "Yönetilebilir etiket sayısı",
+ "pro.feature.tagOrderPersistence": "Özel sıralama: listedeki etiket konumu (= konteyner konumu)",
+ "pro.feature.containerAppOrderPersistence": "Özel sıralama: konteyner içindeki uygulama simgesi konumları",
+ "settings.proStatus.maxSixTags": "En fazla 6",
+ "settings.proStatus.unlimitedTags": "✅ Sınır yok",
+ "settings.proStatus.previewOnly": "Yalnızca önizleme",
+ "settings.proStatus.supported": "✅ Desteklenir"
}
diff --git a/src/Apptag/Localization/uk.json b/src/Apptag/Localization/uk.json
index 0a58f51..f16feb0 100644
--- a/src/Apptag/Localization/uk.json
+++ b/src/Apptag/Localization/uk.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Недоступно",
"settings.proStatus.previewFiveMinutes": "Лише 5 хвилин попереднього перегляду",
- "settings.proGuide.general": "Кольорові контейнери / сітки доступні лише користувачам Pro"
+ "settings.proGuide.general": "Кольорові контейнери / сітки доступні лише користувачам Pro",
+ "pro.feature.customContainerQuota": "Ліміт користувацьких контейнерів",
+ "pro.feature.customContainerQuota.benefit": "Безкоштовна версія може підтримувати 6 користувацьких контейнерів. Pro відкриває необмежене керування тегами.",
+ "pro.prompt.customContainerQuota": "Ліміт користувацьких контейнерів — функція Pro. Розблокуйте Pro, щоб створювати й керувати тегами без обмежень.",
+ "pro.customContainers.quotaShort": "Безкоштовні користувачі можуть використовувати 6 власних тегів (контейнерів): використано %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "Pro розблоковано: користувацькі контейнери без ліміту",
+ "pro.customContainers.quotaPrompt": "Безкоштовна версія може підтримувати %limit% користувацьких контейнерів. Використано %used% / %limit%, залишилося %remaining%. Перейдіть на Pro для необмежених тегів або відредагуйте/видаліть наявні теги, щоб і далі користуватися безкоштовним лімітом.",
+ "pro.customContainers.manageExisting": "Керувати наявними тегами",
+ "pro.tagSorting.previewToast": "Поточний порядок тегів — лише попередній перегляд. Розблокуйте Pro, щоб зберегти його.",
+ "pro.feature.maintainableTagCount": "Кількість тегів для підтримки",
+ "pro.feature.tagOrderPersistence": "Власне сортування: позиція тега у списку (= позиція контейнера)",
+ "pro.feature.containerAppOrderPersistence": "Власне сортування: позиції іконок програм у контейнерах",
+ "settings.proStatus.maxSixTags": "До 6",
+ "settings.proStatus.unlimitedTags": "✅ Без ліміту",
+ "settings.proStatus.previewOnly": "Лише перегляд",
+ "settings.proStatus.supported": "✅ Підтримується"
}
diff --git a/src/Apptag/Localization/vi.json b/src/Apptag/Localization/vi.json
index 64817f7..8ae8be4 100644
--- a/src/Apptag/Localization/vi.json
+++ b/src/Apptag/Localization/vi.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro",
"settings.proStatus.unavailableFeature": "Không khả dụng",
"settings.proStatus.previewFiveMinutes": "Chỉ xem trước 5 phút",
- "settings.proGuide.general": "Chỉ người dùng Pro mới dùng được vùng chứa / lưới màu"
+ "settings.proGuide.general": "Chỉ người dùng Pro mới dùng được vùng chứa / lưới màu",
+ "pro.feature.customContainerQuota": "Hạn mức vùng chứa tùy chỉnh",
+ "pro.feature.customContainerQuota.benefit": "Bản miễn phí có thể quản lý 6 vùng chứa tùy chỉnh. Pro mở khóa quản lý thẻ không giới hạn.",
+ "pro.prompt.customContainerQuota": "Hạn mức vùng chứa tùy chỉnh là tính năng Pro. Mở khóa Pro để tạo và quản lý thẻ không giới hạn.",
+ "pro.customContainers.quotaShort": "Người dùng miễn phí có thể dùng 6 thẻ tùy chỉnh (vùng chứa): đã dùng %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "Đã mở khóa Pro: vùng chứa tùy chỉnh không giới hạn",
+ "pro.customContainers.quotaPrompt": "Bản miễn phí có thể quản lý %limit% vùng chứa tùy chỉnh. Bạn đã dùng %used% / %limit%, còn %remaining%. Nâng cấp Pro để quản lý thẻ không giới hạn, hoặc sửa/xóa thẻ hiện có để tiếp tục dùng hạn mức miễn phí.",
+ "pro.customContainers.manageExisting": "Quản lý thẻ hiện có",
+ "pro.tagSorting.previewToast": "Thứ tự thẻ hiện tại chỉ là bản xem trước. Mở khóa Pro để lưu thứ tự thẻ.",
+ "pro.feature.maintainableTagCount": "Số thẻ có thể quản lý",
+ "pro.feature.tagOrderPersistence": "Sắp xếp tùy chỉnh: vị trí thẻ trong danh sách (= vị trí vùng chứa)",
+ "pro.feature.containerAppOrderPersistence": "Sắp xếp tùy chỉnh: vị trí biểu tượng ứng dụng trong vùng chứa",
+ "settings.proStatus.maxSixTags": "Tối đa 6",
+ "settings.proStatus.unlimitedTags": "✅ Không giới hạn",
+ "settings.proStatus.previewOnly": "Chỉ xem trước",
+ "settings.proStatus.supported": "✅ Hỗ trợ"
}
diff --git a/src/Apptag/Localization/zh-Hans.json b/src/Apptag/Localization/zh-Hans.json
index 4e5f246..e0b1b3f 100644
--- a/src/Apptag/Localization/zh-Hans.json
+++ b/src/Apptag/Localization/zh-Hans.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro 版",
"settings.proStatus.unavailableFeature": "不可用",
"settings.proStatus.previewFiveMinutes": "只可预览 5 分钟",
- "settings.proGuide.general": "仅Pro用户可以使用彩色容器/彩色网格"
+ "settings.proGuide.general": "仅Pro用户可以使用彩色容器/彩色网格",
+ "pro.feature.customContainerQuota": "自定义容器额度",
+ "pro.feature.customContainerQuota.benefit": "免费版可维护 6 个自定义容器。Pro 可创建和管理不限数量的标签。",
+ "pro.prompt.customContainerQuota": "自定义容器额度属于 Pro 功能。解锁 Pro 后,可创建和管理不限数量的标签。",
+ "pro.customContainers.quotaShort": "免费用户可以使用6个自定义标签(容器):已使用 %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "Pro 已解锁:自定义容器不限数量",
+ "pro.customContainers.quotaPrompt": "免费版可维护 %limit% 个自定义容器,当前已使用 %used% / %limit%,还可维护 %remaining% 个。升级 Pro 后可创建和管理不限数量的标签。你也可以编辑或删除已有标签后继续使用免费额度。",
+ "pro.customContainers.manageExisting": "管理已有标签",
+ "pro.tagSorting.previewToast": "当前标签顺序仅为预览。解锁 Pro 后才可保存标签顺序。",
+ "pro.feature.maintainableTagCount": "可维护标签数量",
+ "pro.feature.tagOrderPersistence": "自定义排序:标签在列表中的位置(=容器显示位置)",
+ "pro.feature.containerAppOrderPersistence": "自定义排序:容器内应用图标位置",
+ "settings.proStatus.maxSixTags": "最多 6 个",
+ "settings.proStatus.unlimitedTags": "✅没有限制",
+ "settings.proStatus.previewOnly": "只可预览",
+ "settings.proStatus.supported": "✅支持"
}
diff --git a/src/Apptag/Localization/zh-Hant.json b/src/Apptag/Localization/zh-Hant.json
index 19714aa..ef7bad4 100644
--- a/src/Apptag/Localization/zh-Hant.json
+++ b/src/Apptag/Localization/zh-Hant.json
@@ -343,5 +343,20 @@
"settings.proCompare.pro": "Pro 版",
"settings.proStatus.unavailableFeature": "不可用",
"settings.proStatus.previewFiveMinutes": "只可預覽 5 分鐘",
- "settings.proGuide.general": "僅 Pro 使用者可以使用彩色容器/彩色網格"
+ "settings.proGuide.general": "僅 Pro 使用者可以使用彩色容器/彩色網格",
+ "pro.feature.customContainerQuota": "自訂容器額度",
+ "pro.feature.customContainerQuota.benefit": "免費版可維護 6 個自訂容器。Pro 可建立和管理不限數量的標籤。",
+ "pro.prompt.customContainerQuota": "自訂容器額度屬於 Pro 功能。解鎖 Pro 後,可建立和管理不限數量的標籤。",
+ "pro.customContainers.quotaShort": "免費使用者可以使用 6 個自訂標籤(容器):已使用 %used% / %limit%",
+ "pro.customContainers.quotaUnlimitedShort": "Pro 已解鎖:自訂容器不限數量",
+ "pro.customContainers.quotaPrompt": "免費版可維護 %limit% 個自訂容器,目前已使用 %used% / %limit%,還可維護 %remaining% 個。升級 Pro 後可建立和管理不限數量的標籤。你也可以編輯或刪除既有標籤後繼續使用免費額度。",
+ "pro.customContainers.manageExisting": "管理既有標籤",
+ "pro.tagSorting.previewToast": "目前標籤順序僅為預覽。解鎖 Pro 後才可儲存標籤順序。",
+ "pro.feature.maintainableTagCount": "可維護標籤數量",
+ "pro.feature.tagOrderPersistence": "自訂排序:標籤在列表中的位置(=容器顯示位置)",
+ "pro.feature.containerAppOrderPersistence": "自訂排序:容器內 App 圖示位置",
+ "settings.proStatus.maxSixTags": "最多 6 個",
+ "settings.proStatus.unlimitedTags": "✅沒有限制",
+ "settings.proStatus.previewOnly": "只可預覽",
+ "settings.proStatus.supported": "✅支援"
}
diff --git a/src/Apptag/PreferencesView.swift b/src/Apptag/PreferencesView.swift
index 67f2899..a147190 100644
--- a/src/Apptag/PreferencesView.swift
+++ b/src/Apptag/PreferencesView.swift
@@ -54,6 +54,7 @@
private let settingsWindowWidth: CGFloat = 1000
private let settingsWindowHeight: CGFloat = 640
private let settingsContentWidth: CGFloat = 940
+ private let languageContentWidth: CGFloat = 820
private let generalContentWidth: CGFloat = 720
private let generalLabelWidth: CGFloat = 190
private let generalControlWidth: CGFloat = 500
@@ -93,10 +94,13 @@
@State private var hotkeyStatusToastToken: UUID? = nil
@State private var recordingHotkeyKind: LauncherHotkeyKind? = nil
@State private var hotkeyRecorderMonitor: Any? = nil
+ @State private var settingsEscapeMonitor: Any? = nil
@State private var hotkeySettingsRefreshToken = UUID()
@State private var selectedTab: SettingsTab = .general
@State private var selectedInitialLayoutMode: InitialLayoutMode = .smart
@State private var settingsProPromptFeature: ProFeature? = nil
+ @State private var settingsCustomContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
+ @State private var settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus()
@State private var themePreviewNow = Date()
init(initialTabRawValue: String? = nil) {
@@ -116,11 +120,13 @@
apps = TagEditor.annotate(apps: apps)
let colors = store.tags.mapValues { $0.color }
let customColors = store.tags.compactMapValues { $0.customColor }
+ let quotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
DispatchQueue.main.async {
allApps = apps
tagColors = colors
tagCustomColors = customColors
categoryScheme = store.categoryScheme
+ settingsCustomContainerQuotaOverviewStatus = quotaStatus
}
}
}
@@ -230,6 +236,68 @@
}
}
+ private func installSettingsEscapeMonitor() {
+ guard settingsEscapeMonitor == nil else { return }
+ settingsEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ guard Int(event.keyCode) == kVK_Escape,
+ isSettingsOverlayEscapeEvent(event)
+ else { return event }
+ return handleSettingsEscapeKey() ? nil : event
+ }
+ }
+
+ private func removeSettingsEscapeMonitor() {
+ if let settingsEscapeMonitor {
+ NSEvent.removeMonitor(settingsEscapeMonitor)
+ }
+ settingsEscapeMonitor = nil
+ }
+
+ private func isSettingsOverlayEscapeEvent(_ event: NSEvent) -> Bool {
+ guard let window = preferencesWindow,
+ window.isVisible,
+ let parent = window.parent as? OverlayPanel,
+ parent.isVisible
+ else { return false }
+
+ if let eventWindow = event.window {
+ return eventWindow == window || eventWindow == parent || eventWindow.parent == window
+ }
+ return NSApp.keyWindow == window || NSApp.keyWindow == parent || NSApp.mainWindow == window
+ }
+
+ private func handleSettingsEscapeKey() -> Bool {
+ guard let window = preferencesWindow else { return false }
+ guard !isDataFilePanelPresented, window.attachedSheet == nil else { return false }
+
+ if settingsProPromptFeature != nil {
+ dismissSettingsProPrompt()
+ return true
+ }
+
+ if showApplySystemSchemeConfirmation {
+ withAnimation(.easeOut(duration: 0.14)) {
+ showApplySystemSchemeConfirmation = false
+ }
+ return true
+ }
+
+ if showResetToUncategorizedConfirmation {
+ withAnimation(.easeOut(duration: 0.14)) {
+ showResetToUncategorizedConfirmation = false
+ }
+ return true
+ }
+
+ if recordingHotkeyKind != nil {
+ stopHotkeyRecording(showCancelledToast: true)
+ return true
+ }
+
+ window.performClose(nil)
+ return true
+ }
+
private var preferencesWindow: NSWindow? {
if let taggedWindow = NSApp.windows.first(where: {
$0.identifier?.rawValue == "TagLauncherPreferencesWindow" && $0.isVisible
@@ -242,14 +310,21 @@
.first(where: isPreferencesWindowFallback(_:))
}
+ private var localizedPreferencesWindowTitle: String {
+ tr("menu.preferences").replacingOccurrences(of: "…", with: "")
+ }
+
+ private func refreshPreferencesWindowTitle() {
+ preferencesWindow?.title = localizedPreferencesWindowTitle
+ }
+
private func isPreferencesWindowFallback(_ window: NSWindow) -> Bool {
guard window.isVisible,
!(window is NSPanel),
!(window is OverlayPanel)
else { return false }
- let preferencesTitle = tr("menu.preferences").replacingOccurrences(of: "…", with: "")
- return window.title == preferencesTitle
+ return window.title == localizedPreferencesWindowTitle
}
private func showDataAlert(title: String, message: String) {
@@ -842,6 +917,38 @@
}
}
+ private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
+ if proEntitlement.isUnlocked {
+ return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
+ }
+ return settingsCustomContainerQuotaOverviewStatus
+ }
+
+ private var customContainerQuotaAccessory: some View {
+ let status = effectiveCustomContainerQuotaStatus
+ let text = status.isUnlimited
+ ? tr("pro.customContainers.quotaUnlimitedShort")
+ : tr(
+ "pro.customContainers.quotaShort",
+ replacements: [
+ "%used%": "\(status.used)",
+ "%limit%": "\(status.limit)"
+ ]
+ )
+ return Text(text)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ private func refreshSettingsCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
+ settingsCustomContainerQuotaOverviewStatus = status
+ }
+
+ private func refreshSettingsCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
+ settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
+ }
+
private var shouldShowSettingsFixedProHeader: Bool {
true
}
@@ -932,6 +1039,33 @@
private var proTabComparisonFeatures: [ProTabComparisonFeature] {
[
+ ProTabComparisonFeature(
+ title: tr("pro.feature.maintainableTagCount"),
+ freeStatusKey: "settings.proStatus.maxSixTags",
+ proLockedStatusKey: "settings.proStatus.unlimitedTags",
+ proUnlockedStatusKey: "settings.proStatus.unlimitedTags",
+ freeStyle: .neutral,
+ proLockedStyle: .neutral,
+ proUnlockedStyle: .neutral
+ ),
+ ProTabComparisonFeature(
+ title: tr("pro.feature.tagOrderPersistence"),
+ freeStatusKey: "settings.proStatus.previewOnly",
+ proLockedStatusKey: "settings.proStatus.supported",
+ proUnlockedStatusKey: "settings.proStatus.supported",
+ freeStyle: .neutral,
+ proLockedStyle: .neutral,
+ proUnlockedStyle: .neutral
+ ),
+ ProTabComparisonFeature(
+ title: tr("pro.feature.containerAppOrderPersistence"),
+ freeStatusKey: "settings.proStatus.previewOnly",
+ proLockedStatusKey: "settings.proStatus.supported",
+ proUnlockedStatusKey: "settings.proStatus.supported",
+ freeStyle: .neutral,
+ proLockedStyle: .neutral,
+ proUnlockedStyle: .neutral
+ ),
ProTabComparisonFeature(
title: tr("pro.feature.customTagColors"),
freeStatusKey: "settings.proStatus.unavailableFeature",
@@ -1169,11 +1303,22 @@
return effectiveDisplayMode == mode ? .checkmark : .circle
}
- private func presentSettingsProPrompt(for feature: ProFeature) {
+ private func presentSettingsProPrompt(
+ for feature: ProFeature,
+ customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
+ ) {
proEntitlement.clearTransientOperationState()
+ settingsCustomContainerQuotaStatus = customContainerQuotaStatus
withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
settingsProPromptFeature = feature
}
+ }
+
+ private func presentSettingsCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
+ presentSettingsProPrompt(
+ for: .customContainerQuota,
+ customContainerQuotaStatus: status
+ )
}
private var themePreviewCountdownText: String? {
@@ -1208,6 +1353,38 @@
withAnimation(.easeOut(duration: 0.18)) {
settingsProPromptFeature = nil
}
+ settingsCustomContainerQuotaStatus = nil
+ }
+
+ private func settingsProPromptMessage(for feature: ProFeature) -> String {
+ if feature == .customContainerQuota,
+ let status = settingsCustomContainerQuotaStatus,
+ !status.isUnlimited {
+ return tr(
+ "pro.customContainers.quotaPrompt",
+ replacements: [
+ "%used%": "\(status.used)",
+ "%limit%": "\(status.limit)",
+ "%remaining%": "\(status.remaining)"
+ ]
+ )
+ }
+ return tr(feature.promptMessageKey)
+ }
+
+ private func settingsProPromptSecondaryTitle(for feature: ProFeature) -> String {
+ feature == .customContainerQuota
+ ? tr("pro.customContainers.manageExisting")
+ : tr("pro.card.restore")
+ }
+
+ private func handleSettingsProPromptSecondaryAction(for feature: ProFeature) {
+ if feature == .customContainerQuota {
+ dismissSettingsProPrompt()
+ selectedTab = .tags
+ return
+ }
+ proEntitlement.restorePurchases()
}
private func handleProAccessStateChange(_ newState: ProAccessState) {
@@ -1487,7 +1664,7 @@
switch selectedTab {
case .language:
// Tab 1: Language
- centeredSettingsScrollContent(width: settingsContentWidth) {
+ centeredSettingsScrollContent(width: languageContentWidth) {
LazyVGrid(columns: languageColumns, alignment: .leading, spacing: 6) {
Button {
selectedLanguage = L10n.automaticCode
@@ -1680,10 +1857,19 @@
excludedTagNames: ["Mac自带", defaultGroupName],
isCustomColorUnlocked: proEntitlement.isUnlocked,
onLockedCustomColor: { presentSettingsProPrompt(for: .customTagColors) },
- onRefresh: { scanApps() }
+ onCustomContainerQuotaExceeded: presentSettingsCustomContainerQuotaPrompt,
+ onCustomContainerQuotaStatusChanged: refreshSettingsCustomContainerQuotaStatus,
+ topLeadingAccessory: AnyView(customContainerQuotaAccessory),
+ onRefresh: {
+ refreshSettingsCustomContainerQuotaStatus()
+ scanApps()
+ }
)
}
- .onAppear { scanApps() }
+ .onAppear {
+ refreshSettingsCustomContainerQuotaStatus()
+ scanApps()
+ }
case .data:
// Tab 5: Data
@@ -1908,6 +2094,7 @@
} else {
syncSelectedLanguage()
}
+ refreshPreferencesWindowTitle()
scanApps()
refreshDataState()
showLanguageRefresh()
@@ -1977,15 +2164,15 @@
ProUpgradePromptView(
title: tr(feature.titleKey),
- message: tr(feature.promptMessageKey),
+ message: settingsProPromptMessage(for: feature),
benefitText: tr(feature.benefitKey),
operationText: proEntitlement.operationState.statusMessageKey.map(tr),
unlockTitle: unlockButtonTitle(),
- restoreTitle: tr("pro.card.restore"),
+ restoreTitle: settingsProPromptSecondaryTitle(for: feature),
isBusy: proEntitlement.operationState.isBusy,
onClose: dismissSettingsProPrompt,
onUnlock: { proEntitlement.purchasePro() },
- onRestore: { proEntitlement.restorePurchases() }
+ onRestore: { handleSettingsProPromptSecondaryAction(for: feature) }
)
}
.zIndex(4)
@@ -2017,10 +2204,13 @@
.frame(width: settingsWindowWidth, height: settingsWindowHeight)
.onAppear {
syncSelectedLanguage()
+ refreshPreferencesWindowTitle()
+ installSettingsEscapeMonitor()
showPendingHotkeyWarningIfNeeded()
refreshThemePreviewCountdown()
}
.onDisappear {
+ removeSettingsEscapeMonitor()
stopHotkeyRecording()
}
.onReceive(themePreviewCountdownTimer) { now in
diff --git a/src/Apptag/ProAccessViews.swift b/src/Apptag/ProAccessViews.swift
index 49b65d1..acdfe9f 100644
--- a/src/Apptag/ProAccessViews.swift
+++ b/src/Apptag/ProAccessViews.swift
@@ -97,6 +97,8 @@
return "pro.prompt.export"
case .customTagColors:
return "pro.prompt.customTagColors"
+ case .customContainerQuota:
+ return "pro.prompt.customContainerQuota"
case .unlimitedNotes:
return "pro.prompt.notes"
case .persistentAppSorting:
diff --git a/src/Apptag/ProEntitlement.swift b/src/Apptag/ProEntitlement.swift
index 1674af4..b0b6f46 100644
--- a/src/Apptag/ProEntitlement.swift
+++ b/src/Apptag/ProEntitlement.swift
@@ -7,6 +7,7 @@
case layoutImport
case layoutExport
case customTagColors
+ case customContainerQuota
case unlimitedNotes
case persistentAppSorting
case customHotkeys
@@ -21,6 +22,8 @@
return "pro.feature.export"
case .customTagColors:
return "pro.feature.customTagColors"
+ case .customContainerQuota:
+ return "pro.feature.customContainerQuota"
case .unlimitedNotes:
return "pro.feature.notes"
case .persistentAppSorting:
@@ -40,6 +43,8 @@
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:
@@ -139,6 +144,21 @@
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"
@@ -147,6 +167,7 @@
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"
@@ -225,7 +246,7 @@
static func isUnlocked(_ feature: ProFeature) -> Bool {
switch feature {
- case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .unlimitedNotes, .persistentAppSorting, .customHotkeys:
+ case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .customContainerQuota, .unlimitedNotes, .persistentAppSorting, .customHotkeys:
return accessState().isUnlocked
}
}
@@ -344,6 +365,37 @@
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
diff --git a/src/Apptag/TagEditorView.swift b/src/Apptag/TagEditorView.swift
index 1d2963b..701e532 100644
--- a/src/Apptag/TagEditorView.swift
+++ b/src/Apptag/TagEditorView.swift
@@ -11,6 +11,8 @@
let excludedTagNames: Set<String>
let isCustomColorUnlocked: Bool
let onLockedCustomColor: () -> Void
+ let onCustomContainerQuotaExceeded: (ProCustomContainerQuotaStatus) -> Void
+ let onCustomContainerQuotaStatusChanged: (ProCustomContainerQuotaStatus) -> Void
var topLeadingAccessory: AnyView? = nil
var onRefresh: (() -> Void)?
@@ -101,7 +103,8 @@
HStack(spacing: 4) {
ForEach(TagColor.allIndices, id: \.self) { idx in
ColorSwatch(index: idx, isSelected: !hasCustomColor && idx == colorIndex) {
- TagEditor.setColor(idx, for: tagName)
+ let result = TagEditor.setColor(idx, for: tagName)
+ guard handleMutationResult(result) else { return }
tagColors[tagName] = idx
tagCustomColors.removeValue(forKey: tagName)
onRefresh?()
@@ -179,7 +182,8 @@
private func commitTagRename(_ oldName: String) {
let newName = editingTagText.trimmingCharacters(in: .whitespaces)
guard !newName.isEmpty, newName != oldName else { editingTagName = nil; return }
- TagEditor.renameTag(from: oldName, to: newName)
+ let result = TagEditor.renameTag(from: oldName, to: newName)
+ guard handleMutationResult(result) else { return }
tagColors[newName] = tagColors[oldName]
tagColors.removeValue(forKey: oldName)
tagCustomColors[newName] = tagCustomColors[oldName]
@@ -192,16 +196,19 @@
private func addNewTag() {
let name = newTagNameText.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
+ let result = TagEditor.createTag(name, color: newTagColorIndex)
+ guard handleMutationResult(result) else { return }
tagColors[name] = newTagColorIndex
- TagEditor.createTag(name, color: newTagColorIndex)
addingNewTag = false
newTagNameText = ""
+ onRefresh?()
}
private func deleteTag(_ name: String) {
TagEditor.deleteTagCompletely(name)
tagColors.removeValue(forKey: name)
tagCustomColors.removeValue(forKey: name)
+ onCustomContainerQuotaStatusChanged(ProEntitlementPolicy.customContainerQuotaStatus())
onRefresh?()
}
@@ -275,8 +282,24 @@
return
}
tagCustomColors[tagName] = customColor
+ onCustomContainerQuotaStatusChanged(ProEntitlementPolicy.customContainerQuotaStatus())
onRefresh?()
}
)
}
+
+ @discardableResult
+ private func handleMutationResult(_ result: TagEditorMutationResult) -> Bool {
+ switch result {
+ case .saved:
+ onCustomContainerQuotaStatusChanged(ProEntitlementPolicy.customContainerQuotaStatus())
+ return true
+ case .noChange:
+ return false
+ case .blockedCustomContainerQuota(let status):
+ onCustomContainerQuotaStatusChanged(status)
+ onCustomContainerQuotaExceeded(status)
+ return false
+ }
+ }
}
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 1cf5f75..b0b9c02 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -2,6 +2,23 @@
## [Unreleased]
+## [8.3.1] — 2026-07-01
+
+- 修复设置窗口在切换语言后,标题栏仍停留在旧语言的问题;标题会在设置窗口出现和语言切换通知时同步刷新
+- 修复免费版标签额度提示“已使用 X / 6”在新建、改名、改色或删除标签后不实时更新的问题
+- 修复标签编辑文本框点击路径,避免编辑标签时因重复转发同一次鼠标事件导致卡死或崩溃
+- 新增 `Scripts/settings_language_title_qa.sh` 和 `Scripts/tag_editor_mouse_event_qa.sh`,并扩展自定义容器额度 QA 覆盖实时额度刷新
+- 版本号更新为 `8.3.1`,Build 更新为 `20260701.2302`
+
+## [8.3.0] — 2026-07-01
+
+- 新增免费版自定义维护容器额度:免费用户最多维护 6 个自定义标签 / 容器;系统默认分类在被改名、改色或手动调整成员后计入额度,Pro 用户不限制
+- 第 7 个自定义维护动作不再写入临时数据,会弹出 Pro 提示,并提供“管理已有标签”入口
+- 标签拖动排序改为免费仅预览、Pro 才持久保存;容器内应用排序仍沿用同一 Pro 排序保存逻辑
+- Pro 页对比表新增前三项:可维护标签数量、标签列表位置排序、容器内应用图标位置排序;同步补齐 29 个语种
+- 新增 `Scripts/pro_custom_container_quota_qa.sh`,并扩展 Pro / 本地化 QA 门禁覆盖新额度、排序预览和对比表文案
+- 版本号更新为 `8.3.0`,Build 更新为 `20260701.1530`
+
## [8.2.9] — 2026-06-30
- 统一已解锁 Pro 用户在设置页“语言 / Pro / 关于”顶部状态行:居中显示 `Pro 已解锁` 胶囊和“您当前是尊贵的 Pro 用户”
diff --git a/src/Docs/Requirements/2026-07-01-pro-custom-container-quota-prd.md b/src/Docs/Requirements/2026-07-01-pro-custom-container-quota-prd.md
new file mode 100644
index 0000000..8a07223
--- /dev/null
+++ b/src/Docs/Requirements/2026-07-01-pro-custom-container-quota-prd.md
@@ -0,0 +1,403 @@
+# PRD:自定义标签额度、排序保存与 Pro 转化
+
+日期:2026-07-01
+状态:产品方案讨论稿,待研发评审与实现
+适用产品:TagLauncher macOS App
+
+## 1. 功能名
+
+免费版自定义标签额度、标签排序预览与 Pro 无限容器管理
+
+## 2. 需求描述
+
+TagLauncher 的核心价值是用标签 / 容器管理 Mac 应用。为了提高 Pro 付费转化,需要把 Pro 权益更直接地绑定到核心功能上:免费用户可以体验默认智能分类和基础整理能力,但当用户开始搭建自己的长期容器体系时,应自然触发 Pro 升级理由。
+
+本需求不采用“维护次数”限制。免费版限制的是可个性化维护的自定义标签 / 容器数量,以及标签拖动排序的保存能力。
+
+## 3. 背景与目标
+
+### 3.1 背景
+
+当前 Pro 权益已覆盖高级主题、自定义标签颜色、自定义快捷键、无限备注、数据导入导出、手动排序持久化等能力。但这些能力中,部分并不直接指向 TagLauncher 最核心的容器管理价值。
+
+用户最可能为 TagLauncher 付费的理由,是需要长期维护更多自定义容器、构建更完整的应用分组体系。因此,本需求希望围绕“标签维护规模”建立更清晰的免费 / Pro 边界。
+
+### 3.2 目标
+
+| 目标 | 说明 |
+|:---|:---|
+| 提升 Pro 转化 | 把升级触点放在用户开始扩展自定义容器体系、希望沉淀长期排序习惯时。 |
+| 保留新用户激活价值 | 默认智能分类仍可免费使用,避免下载后立刻被付费墙挡住。 |
+| 规则可解释 | 用户应能理解“免费可维护 6 个自定义容器,Pro 不限”。 |
+| 避免数据被扣押 | 老用户和导入数据不能被删除或隐藏;超额时应保留查看和启动能力。 |
+| 避免惩罚试错 | 不限制改名、改色等操作次数,避免用户不敢整理。 |
+| 保持体验可感知 | 免费用户可以预览标签拖动排序效果,但保存长期排序结果需要 Pro。 |
+
+## 4. 术语定义
+
+| 术语 | 定义 | 是否计入免费额度 |
+|:---|:---|:---|
+| 系统分类 | App 固定存在或系统生成的分类,例如“未分类”“Mac 自带”。 | 不计入 |
+| 默认智能分类 | SmartStart / 智能初始化生成的默认分类,当前约 12 个。 | 默认不计入 |
+| 自定义标签 / 自建容器 | 用户手动创建的标签或容器。 | 计入 |
+| 自定义维护容器 | 用户对默认智能分类进行个性化维护后形成的容器,或用户自建容器。 | 计入 |
+| 个性化维护 | 会让默认分类变成用户长期自定义容器的操作,例如改名、改基础颜色、手动调整应用归属。 | 会触发计入 |
+| 减法操作 | 删除、隐藏、清理、合并,目的是减少或恢复到免费额度内。 | 不新增额度 |
+| 标签拖动排序预览 | 用户拖动标签并看到临时顺序变化,但该顺序不作为长期结果保存。 | 不计入 |
+| 标签排序保存 | 用户拖动标签后的顺序被保留,并在后续打开、刷新或重启后继续生效。 | Pro 权益 |
+
+## 5. 产品结论
+
+### 5.1 推荐方案
+
+免费用户最多可维护 6 个自定义维护容器。
+
+这 6 个可以来自:
+
+- 用户手动新建的标签。
+- 用户从导入 / 恢复数据中带来的自定义标签。
+- 用户对默认智能分类进行个性化维护后转化出的标签。
+
+Pro 用户可维护不限数量的自定义标签 / 容器。
+
+标签拖动排序的保存能力属于 Pro 权益。免费用户可以拖动标签预览排序效果,但不能保存新的标签顺序。
+
+### 5.2 不采用方案
+
+不采用“维护次数”限制,例如“免费用户只能改名 / 改颜色 100 次”。
+
+原因:
+
+- 次数型限制会惩罚用户试错和整理行为。
+- 用户不容易理解什么算一次维护。
+- 撤销、批量操作、导入恢复、失败重试会带来大量解释和 QA 成本。
+- 付费理由会从“升级组织能力”变成“避免被扣次数”,口碑风险更高。
+
+## 6. 免费版规则
+
+### 6.1 免费用户始终可用
+
+| 能力 | 免费规则 |
+|:---|:---|
+| 使用默认智能分类 | 可浏览、启动、搜索默认分类中的应用。 |
+| 使用系统分类 | “未分类”“Mac 自带”等系统分类可正常使用。 |
+| 创建自定义标签 | 可创建,直到自定义维护容器数量达到 6 个。 |
+| 删除 / 隐藏 / 清理标签 | 允许,且用于释放额度或回到免费限制内。 |
+| 拖动标签排序 | 允许预览排序效果,但不保存新的标签顺序。 |
+| 启动 App | 不受标签额度影响。 |
+| Quick Search | 基础搜索不受标签额度影响。 |
+
+### 6.2 免费用户受限
+
+| 操作 | 规则 |
+|:---|:---|
+| 创建第 7 个自定义标签 | 阻止创建,并提示升级 Pro。 |
+| 把第 7 个默认智能分类改名 | 阻止改名,并提示升级 Pro。 |
+| 把第 7 个默认智能分类改基础颜色 | 阻止改色,并提示升级 Pro。 |
+| 手动重组第 7 个默认智能分类的应用归属 | 阻止维护动作,并提示升级 Pro。 |
+| 保存标签拖动排序 | 免费用户只能预览,保存排序需要 Pro。 |
+| 对超额自定义容器继续维护 | 允许查看和启动;继续改名、改色、增加维护需要 Pro。 |
+
+### 6.3 默认智能分类的处理
+
+默认智能分类免费可用,但不是免费无限维护额度。
+
+| 场景 | 行为 |
+|:---|:---|
+| 用户只是浏览 / 启动默认分类中的应用 | 不计入 6 个额度。 |
+| 用户删除或隐藏默认分类 | 不计入 6 个额度。 |
+| 用户改默认分类名称 | 该默认分类转为自定义维护容器,占用 1 个额度。 |
+| 用户改默认分类基础颜色 | 该默认分类转为自定义维护容器,占用 1 个额度。 |
+| 用户手动调整默认分类中的应用归属 | 该默认分类转为自定义维护容器,占用 1 个额度。 |
+| 用户已经有 6 个自定义维护容器,再维护第 7 个默认分类 | 提示升级 Pro,不执行该维护动作。 |
+
+## 7. Pro 规则
+
+Pro 用户解锁不限数量的自定义标签 / 容器维护。
+
+| 能力 | Pro 规则 |
+|:---|:---|
+| 创建标签 | 不限制数量。 |
+| 个性化维护默认分类 | 不限制数量。 |
+| 改名 / 改基础颜色 | 不限制已拥有的标签数量。 |
+| 维护应用归属 | 不限制自定义容器数量。 |
+| 保存标签拖动排序 | 不限制,拖动完成后保留排序结果。 |
+| 老用户超额数据 | 可完整继续维护。 |
+
+## 8. 额度计算规则
+
+### 8.1 计入额度
+
+| 动作 | 计入方式 |
+|:---|:---|
+| 新建用户标签 | 新增 1 个自定义维护容器。 |
+| 导入 / 恢复自定义标签 | 进入自定义维护容器集合。 |
+| 默认智能分类改名 | 转为自定义维护容器,新增 1 个。 |
+| 默认智能分类改基础颜色 | 转为自定义维护容器,新增 1 个。 |
+| 默认智能分类被手动调整应用归属 | 转为自定义维护容器,新增 1 个。 |
+
+### 8.2 不新增额度
+
+| 动作 | 规则 |
+|:---|:---|
+| 浏览分类 | 不新增额度。 |
+| 启动应用 | 不新增额度。 |
+| 搜索应用 | 不新增额度。 |
+| 删除标签 | 不新增额度;如果删除的是自定义维护容器,则释放额度。 |
+| 隐藏默认分类 | 不新增额度。 |
+| 编辑已计入额度的标签 | 不新增额度,只要该标签已经属于 6 个可维护容器之一。 |
+| 清理或合并标签 | 不新增额度,目的是帮助用户回到免费限制内。 |
+
+## 9. 老用户与历史数据
+
+### 9.1 基本原则
+
+不得因为免费额度限制删除、隐藏或破坏用户已有数据。
+
+### 9.2 老用户已有超过 6 个自定义标签
+
+| 场景 | 规则 |
+|:---|:---|
+| 查看已有标签 | 允许。 |
+| 从已有标签启动 App | 允许。 |
+| 删除 / 合并 / 清理已有标签 | 允许。 |
+| 新增更多自定义标签 | 需要 Pro。 |
+| 继续维护超额标签 | 需要 Pro,或先清理到 6 个可维护容器以内。 |
+
+### 9.3 从 Pro 降级为免费
+
+如果用户失去 Pro,已有自定义容器不删除、不隐藏。用户仍可查看和启动,但超出免费额度的维护能力需要重新解锁 Pro,或通过删除 / 合并回到免费额度内。
+
+## 10. 页面与交互要求
+
+### 10.1 自定义容器额度计数
+
+标签设置页应展示免费用户当前还能维护多少个自定义容器,让用户在触发限制前就能理解额度状态。
+
+免费用户建议展示:
+
+> 免费版可维护 6 个自定义容器,当前还可维护 X 个。升级 Pro 后可创建和管理不限数量的标签。
+
+也可以在空间受限时使用短文案:
+
+> 自定义容器:已使用 X / 6
+
+Pro 用户建议展示:
+
+> Pro 已解锁:自定义容器不限数量
+
+额度计数规则:
+
+| 状态 | 展示要求 |
+|:---|:---|
+| 免费用户,已使用 0-5 个额度 | 显示已使用数量和剩余额度。 |
+| 免费用户,已使用 6 个额度 | 显示已用满,并在后续超额维护动作中触发 Pro 提示。 |
+| 免费用户,历史数据超过 6 个额度 | 显示已超出免费额度,但不得删除或隐藏已有标签。 |
+| Pro 用户 | 显示不限数量,不展示 6 个上限压力。 |
+
+### 10.2 标签设置页
+
+标签设置页应让用户知道当前额度状态,并让用户能管理已有标签,以便删除、合并或清理标签后释放免费额度。
+
+建议展示:
+
+> 自定义容器:已使用 X / 6
+
+当用户已是 Pro:
+
+> Pro 已解锁:自定义容器不限数量
+
+### 10.3 新建标签
+
+用户点击“新建标签”时:
+
+| 状态 | 行为 |
+|:---|:---|
+| 免费用户,已使用 0-5 个额度 | 正常创建。 |
+| 免费用户,已使用 6 个额度 | 不创建,显示 Pro 提示。 |
+| Pro 用户 | 正常创建。 |
+
+### 10.4 个性化维护默认分类
+
+当免费用户首次对默认智能分类做个性化维护时,如果仍有额度,允许操作,并可轻提示该分类已计入自定义容器额度。
+
+当免费用户额度已满时,阻止该维护动作,提示升级 Pro。
+
+### 10.5 Pro 提示文案
+
+当用户触发第 7 个自定义维护动作时,直接拦截,不创建临时标签、不保存临时改动、不提供预览逻辑。
+
+本节文案仅适用于“自定义容器额度已满”的拦截场景;标签拖动排序采用预览逻辑,见 10.7。
+
+标题建议:
+
+> 自定义容器已用满
+
+正文建议:
+
+> 免费版可维护 6 个自定义标签。升级 Pro 后可创建和管理不限数量的标签,让你的应用分组保持完整。你也可以编辑或删除已有标签后继续使用免费额度。
+
+按钮建议:
+
+| 按钮 | 用途 |
+|:---|:---|
+| 升级 Pro | 进入购买流程。 |
+| 管理已有标签 | 回到标签管理页,帮助用户删除、合并或清理标签。 |
+
+### 10.6 Pro 页 / 功能对比
+
+Pro 页应新增或更新一行:
+
+| 功能 | 免费版 | Pro 版 |
+|:---|:---|:---|
+| 自定义标签 / 容器维护 | 6 个 | 不限数量 |
+| 标签排序保存 | 仅预览 | 可保存 |
+
+### 10.7 标签拖动排序保存
+
+标签拖动排序应作为独立的 Pro 权益处理,交互参考已实现的“在容器内部拖动改变 App 图标顺序,免费版用户只能预览”。
+
+| 用户状态 | 行为 |
+|:---|:---|
+| 免费用户 | 可以拖动标签,并看到临时排序效果。 |
+| 免费用户拖动结束 | 不保存新的标签顺序,展示 Pro 提示。 |
+| 免费用户重新打开 / 刷新 / 触发标签列表重建 | 标签顺序恢复为当前已保存顺序。 |
+| Pro 用户 | 可以拖动标签,拖动结束后保存新的标签顺序。 |
+| Pro 用户重新打开 / 刷新 / 触发标签列表重建 | 标签顺序保持为上次保存结果。 |
+
+免费用户的提示方式应与现有 App 图标排序预览提示保持一致,不新增独立视觉样式。
+
+建议文案:
+
+> 当前仅为预览。升级 Pro 后可保存标签排序。
+
+交互边界:
+
+- 不在拖动开始前阻断用户。
+- 免费用户拖动过程应保持顺畅,可以看到排序预览。
+- 拖动结束后不保存新的顺序。
+- 历史已保存的标签顺序不被清空。
+- 本需求不改变容器内部 App 图标排序的现有规则。
+
+## 11. 用户旅程
+
+### 11.1 新用户正常激活
+
+1. 用户安装并打开 TagLauncher。
+2. 用户完成智能初始化,看到约 12 个默认分类。
+3. 用户可直接浏览默认分类并启动应用。
+4. 用户没有被“默认分类超过免费额度”提示打断。
+
+### 11.2 免费用户创建第 7 个自定义标签
+
+1. 用户已经有 6 个自定义维护容器。
+2. 用户点击“新建标签”。
+3. 系统不创建第 7 个自定义标签。
+4. 系统展示 Pro 提示。
+5. 用户可选择升级 Pro,或返回管理已有标签。
+
+### 11.3 免费用户改造默认分类
+
+1. 用户已有 5 个自定义维护容器。
+2. 用户把一个默认智能分类改名。
+3. 系统允许改名,并将该分类计入自定义维护容器。
+4. 用户额度变为 6 / 6。
+5. 用户再尝试改造另一个默认分类时,系统提示升级 Pro。
+
+### 11.4 老用户超额
+
+1. 老用户已有 10 个自定义标签。
+2. 用户升级到带有本限制的版本。
+3. 已有 10 个标签仍可查看和启动。
+4. 用户尝试新增第 11 个标签时,提示升级 Pro。
+5. 用户可删除或合并标签,回到免费额度内。
+
+### 11.5 免费用户预览标签排序
+
+1. 免费用户希望调整标签展示顺序。
+2. 用户拖动标签,界面展示拖动后的临时排序效果。
+3. 用户松手完成拖动。
+4. 系统提示当前仅为预览,保存标签排序需要 Pro。
+5. 用户重新打开、刷新或触发标签列表重建后,标签顺序恢复为当前已保存顺序。
+
+### 11.6 Pro 用户保存标签排序
+
+1. Pro 用户拖动标签调整顺序。
+2. 用户松手完成拖动。
+3. 系统保存新的标签顺序。
+4. 用户重新打开、刷新或触发标签列表重建后,标签顺序保持为上次保存结果。
+
+## 12. 用户故事
+
+| 编号 | 用户故事 | 验收条件 |
+|:---|:---|:---|
+| TAG-QUOTA-01 | 作为新用户,我希望默认智能分类可以直接使用,以便先体验应用分组价值。 | 默认智能分类不因数量超过 6 而触发付费墙。 |
+| TAG-QUOTA-02 | 作为免费用户,我希望可以创建少量自定义标签,以便整理自己的常用应用。 | 免费用户可维护最多 6 个自定义维护容器。 |
+| TAG-QUOTA-03 | 作为免费用户,我希望知道为什么第 7 个标签需要 Pro,以便理解升级价值。 | 第 7 个自定义维护动作被阻止,并展示清晰的 Pro 提示。 |
+| TAG-QUOTA-04 | 作为免费用户,我希望删除或合并标签后恢复额度,以便继续整理。 | 删除自定义维护容器后释放额度。 |
+| TAG-QUOTA-05 | 作为 Pro 用户,我希望不受标签数量限制,以便建立完整应用容器体系。 | Pro 用户可创建和维护不限数量的自定义标签。 |
+| TAG-QUOTA-06 | 作为老用户,我希望已有标签不被删除,以便继续访问我的整理结果。 | 超额历史标签仍可查看和启动。 |
+| TAG-SORT-01 | 作为免费用户,我希望能拖动标签看看排序效果,以便判断升级 Pro 后是否有价值。 | 免费用户可拖动标签并看到临时排序效果。 |
+| TAG-SORT-02 | 作为免费用户,我希望知道为什么排序没有保存,以便理解 Pro 权益边界。 | 免费用户拖动结束后看到“仅预览,保存需 Pro”的提示。 |
+| TAG-SORT-03 | 作为 Pro 用户,我希望保存自定义标签顺序,以便长期保持自己的容器浏览习惯。 | Pro 用户拖动标签后,新的标签顺序被保存并持续生效。 |
+
+## 13. 验收标准
+
+| 编号 | 验收项 |
+|:---|:---|
+| QA-TQ-01 | 新用户默认智能分类约 12 个不计入免费额度,不触发 Pro 提示。 |
+| QA-TQ-02 | 免费用户可创建第 1-6 个自定义标签。 |
+| QA-TQ-03 | 免费用户创建第 7 个自定义标签时被阻止,并看到 Pro 提示。 |
+| QA-TQ-04 | 免费用户删除 1 个自定义标签后,可再次创建 1 个。 |
+| QA-TQ-05 | 免费用户改名默认智能分类时,该分类计入自定义维护容器额度。 |
+| QA-TQ-06 | 免费用户已满 6 个额度后,不能改造第 7 个默认智能分类。 |
+| QA-TQ-07 | Pro 用户可创建和维护超过 6 个自定义标签。 |
+| QA-TQ-08 | 老用户已有超过 6 个标签时,数据不被删除、不被隐藏。 |
+| QA-TQ-09 | 老用户免费状态下新增更多自定义标签需要 Pro。 |
+| QA-TQ-10 | Pro 页功能对比展示“自定义标签 / 容器维护:免费 6 个,Pro 不限”。 |
+| QA-TQ-11 | 免费用户可看到自定义容器额度计数,例如“已使用 X / 6”或“当前还可维护 X 个”。 |
+| QA-TQ-12 | 触发第 7 个自定义维护动作时不创建临时标签、不保存临时改动、不进入预览,只展示“升级 Pro”和“管理已有标签”。 |
+| QA-TS-01 | 免费用户可以拖动标签,并看到排序预览效果。 |
+| QA-TS-02 | 免费用户拖动标签结束后,不保存新的标签顺序。 |
+| QA-TS-03 | 免费用户重新打开、刷新或触发标签列表重建后,标签顺序恢复为当前已保存顺序。 |
+| QA-TS-04 | 免费用户拖动标签结束后,看到与 App 图标排序预览一致风格的 Pro 提示。 |
+| QA-TS-05 | Pro 用户拖动标签结束后,新的标签顺序被保存。 |
+| QA-TS-06 | Pro 用户重新打开、刷新或触发标签列表重建后,标签顺序保持为上次保存结果。 |
+| QA-TS-07 | 历史已保存的标签顺序不因本需求上线被清空。 |
+| QA-TS-08 | 本需求不影响容器内部 App 图标排序的既有免费预览规则。 |
+
+## 14. 不做范围
+
+| 不做项 | 说明 |
+|:---|:---|
+| 维护次数限制 | 不做改名 / 改色 / 调整次数的消耗型额度。 |
+| 默认分类总数限制 | 默认智能分类不因为数量超过 6 而触发付费墙。 |
+| 自动删除超额标签 | 不删除、不隐藏老用户或导入数据中的超额标签。 |
+| 标签图标能力 | 本需求不新增标签图标或上传图标。 |
+| App 图标排序规则 | 不调整容器内部 App 图标排序的现有 Pro 预览 / 保存规则。 |
+| 新增排序视觉样式 | 不为标签排序新增一套独立提示视觉,沿用现有 Pro 提示体系。 |
+| 订阅模式 | 本需求不改变 Pro 一次性买断策略。 |
+
+## 15. 指标建议
+
+| 指标 | 观察目的 |
+|:---|:---|
+| 智能初始化完成率 | 确认默认分类不被付费墙干扰。 |
+| 第 7 个自定义标签触达率 | 衡量升级触点覆盖面。 |
+| Pro 提示点击率 | 衡量文案和触发时机是否有效。 |
+| 触达后 7 天购买率 | 衡量该权益对转化的真实贡献。 |
+| 触达后流失率 / 卸载率 | 监控限制是否过重。 |
+| 删除 / 合并标签行为 | 判断用户是否能理解并管理额度。 |
+| 标签排序预览触达率 | 衡量免费用户是否会尝试调整标签顺序。 |
+| 标签排序预览后的 Pro 点击率 | 衡量“保存标签排序”权益是否具备转化价值。 |
+
+## 16. 决策记录
+
+| 日期 | 决策 |
+|:---|:---|
+| 2026-07-01 | 不采用维护次数限制。 |
+| 2026-07-01 | 免费额度限制对象定义为“自定义维护容器”,不是所有标签总数。 |
+| 2026-07-01 | 默认智能分类可免费使用;一旦被个性化维护,则计入 6 个免费额度。 |
+| 2026-07-01 | V1 方案按免费 6 个自定义维护容器编写。 |
+| 2026-07-01 | 增加自定义容器额度计数;第 7 个自定义维护动作直接拦截,不做预览。 |
+| 2026-07-01 | 标签拖动排序保存定义为 Pro 权益;免费用户可以拖动预览,但不保存新的标签顺序。 |
diff --git a/src/Docs/Requirements/free-vs-pro-feature-memo.md b/src/Docs/Requirements/free-vs-pro-feature-memo.md
new file mode 100644
index 0000000..3938903
--- /dev/null
+++ b/src/Docs/Requirements/free-vs-pro-feature-memo.md
@@ -0,0 +1,162 @@
+# 免费版与 Pro 版功能对比备忘
+
+更新时间:2026-07-01
+维护目的:记录 TagLauncher 免费版限制与 Pro 权益的当前产品口径。后续围绕免费 / Pro 主题的变化,应优先更新本文件,避免不同 PRD、实现计划和历史讨论之间互相冲突。
+当前状态:包含已实现能力与 2026-07-01 新增讨论中的“自定义标签额度”待实现能力。
+
+## 1. 使用规则
+
+- 本文件是免费 / Pro 权益的业务备忘,不替代具体 PRD、研发计划或 QA 报告。
+- 当历史 PRD 与本文件冲突时,应以本文件中标注为“当前口径”的内容为准,再回查对应 PRD 或代码确认实现状态。
+- 每次新增、删除或调整 Pro 权益时,应更新:
+ - 功能对比表。
+ - 限制边界。
+ - 老用户 / 降级 / 导入恢复规则。
+ - 状态标记:已实现、待实现、已废弃或待确认。
+
+## 2. 总体原则
+
+| 原则 | 说明 |
+|:---|:---|
+| 免费版要能体验核心价值 | 用户应能免费体验应用启动、默认智能分类和基础标签整理。 |
+| Pro 应围绕长期组织能力 | Pro 重点解锁更大规模、更稳定、更个性化的应用容器管理。 |
+| 不惩罚试错 | 不采用“维护次数”这类会让用户不敢改名、改色、整理的限制。 |
+| 不扣押数据 | 免费限制不得删除、隐藏或破坏用户已有标签、备注和布局数据。 |
+| 限制要可解释 | 文案应优先说“免费额度 / Pro 不限”,避免让用户觉得被突然惩罚。 |
+
+## 3. 功能对比总表
+
+| 功能 | 免费版 | Pro 版 | 状态 | 备注 |
+|:---|:---|:---|:---|:---|
+| App 启动 | 支持 | 支持 | 当前已实现 | 核心基础能力,不作为 Pro 限制。 |
+| Quick Search 基础搜索 | 支持 | 支持 | 当前已实现 | 自定义快捷键另算 Pro 能力。 |
+| 默认智能分类 | 可使用 | 可使用并可全面维护 | 当前口径 | 默认约 12 个分类不因数量触发付费墙。 |
+| 系统分类 | 可使用 | 可使用 | 当前口径 | 包括“未分类”“Mac 自带”等,不计入自定义额度。 |
+| 自定义标签 / 容器维护 | 最多 6 个自定义维护容器 | 不限数量 | 待实现 | 详见《2026-07-01-pro-custom-container-quota-prd.md》。 |
+| 默认分类个性化维护 | 计入 6 个自定义维护容器额度 | 不限数量 | 待实现 | 改名、改基础颜色、手动调整应用归属都算个性化维护。 |
+| 删除 / 隐藏 / 合并标签 | 支持 | 支持 | 当前口径 | 减法操作不新增额度;删除自定义容器释放额度。 |
+| 基础标签颜色 | 对已可维护标签可用 | 不限数量使用 | 当前口径 / 待整合 | 免费用户改默认分类基础颜色时会让该分类计入额度。 |
+| 自定义标签颜色 | 不可新增或修改;已有颜色继续显示 | 支持 | 当前已实现 | 自定义任意颜色属于 Pro。 |
+| 应用备注 | 最多 3 个非空自定义备注 | 无限备注 | 当前已实现 | 编辑已有备注不增加额度;删除备注释放额度。 |
+| 自定义快捷键 | 不支持 | 支持 | 当前已实现 | 免费用户使用默认快捷键。 |
+| 高级主题 | 可 5 分钟预览 | 永久使用 | 当前已实现 | 免费主题当前为默认和黑色。 |
+| 彩色容器 / 彩色网格 | 可 5 分钟预览 | 永久使用 | 当前已实现 | 与 Pro 主题相同预览逻辑。 |
+| 手动应用排序持久化 | 可临时预览,不保存 | 保存排序 | 当前已实现 | 免费用户可拖动预览;只有 Pro 持久保存。 |
+| 数据导入导出 | 不支持 | 支持 | 当前已实现 | 包括分类、布局、备份恢复相关能力。 |
+| 老付费用户权益 | 符合条件自动 Pro | Pro | 当前已实现 | 首个免费 + Pro 版本前的付费用户保护。 |
+| 恢复购买 | 可用于恢复 Pro | 可用于多设备恢复 | 当前已实现 | 已是 Pro 时不应继续展示无意义的恢复购买入口。 |
+
+## 4. 自定义标签额度备忘
+
+### 4.1 当前决策
+
+免费用户最多可维护 6 个自定义维护容器。
+
+计入额度的对象:
+
+- 用户手动新建的标签。
+- 导入 / 恢复数据中的用户自定义标签。
+- 被用户个性化维护过的默认智能分类。
+
+不计入额度的对象:
+
+- 未分类。
+- Mac 自带。
+- 默认智能分类在未被个性化维护前的原始状态。
+- 用户只是浏览、搜索、启动应用的行为。
+
+### 4.2 默认智能分类
+
+默认智能分类免费可用,但不是 12 个免费自定义名额。
+
+| 用户行为 | 免费版结果 |
+|:---|:---|
+| 浏览默认分类 | 不计入额度。 |
+| 从默认分类启动 App | 不计入额度。 |
+| 删除 / 隐藏默认分类 | 不计入额度。 |
+| 改默认分类名称 | 该分类转为自定义维护容器,占用 1 个额度。 |
+| 改默认分类基础颜色 | 该分类转为自定义维护容器,占用 1 个额度。 |
+| 手动调整默认分类应用归属 | 该分类转为自定义维护容器,占用 1 个额度。 |
+| 已满 6 个额度后继续改造默认分类 | 阻止该维护动作,提示升级 Pro。 |
+
+### 4.3 老用户与降级
+
+- 已有超额标签不删除、不隐藏。
+- 免费用户仍可查看超额标签并从中启动 App。
+- 免费用户可删除、合并、清理标签以回到额度内。
+- 超额状态下新增更多标签、继续维护更多超额容器,需要 Pro。
+- Pro 用户失去 Pro 后,已有超额数据保留,但超出免费额度的继续维护需要重新解锁 Pro 或清理回额度内。
+
+## 5. 备注额度备忘
+
+| 场景 | 免费版 | Pro 版 |
+|:---|:---|:---|
+| 新增第 1-3 个非空应用备注 | 允许 | 允许 |
+| 新增第 4 个非空应用备注 | 阻止并提示 Pro | 允许 |
+| 编辑已有备注 | 允许 | 允许 |
+| 删除 / 清空备注 | 释放额度 | 不受限制 |
+
+## 6. 主题与显示模式备忘
+
+| 能力 | 免费版 | Pro 版 |
+|:---|:---|:---|
+| 免费主题 | 默认、黑色 | 默认、黑色也可用 |
+| Pro 主题 | 5 分钟预览 | 永久使用 |
+| 彩色容器 | 5 分钟预览 | 永久使用 |
+| 彩色网格 | 5 分钟预览 | 永久使用 |
+
+预览原则:
+
+- 免费用户可以预览,目的是理解 Pro 效果。
+- 预览结束后恢复到可用状态。
+- 预览不应破坏用户标签、备注、排序等真实数据。
+
+## 7. 数据与排序备忘
+
+| 能力 | 免费版 | Pro 版 |
+|:---|:---|:---|
+| 手动拖动应用排序 | 可临时预览 | 可预览并保存 |
+| 排序持久化 | 不保存 | 保存 |
+| 分类 / 布局导出 | 不支持 | 支持 |
+| 分类 / 布局导入 | 不支持 | 支持 |
+| 备份恢复 | 受限或不可执行 | 支持 |
+
+已确认口径:容器内应用拖动排序当前是“免费可拖动预览、Pro 才能持久保存”,不是“免费用户完全不能拖动”。
+
+## 8. Pro 提示文案方向
+
+### 8.1 自定义标签额度
+
+标题:
+
+> 自定义容器已用满
+
+正文:
+
+> 免费版可维护 6 个自定义标签。升级 Pro 后可创建和管理不限数量的标签,让你的应用分组保持完整。
+
+按钮:
+
+- 升级 Pro
+- 管理已有标签
+- 稍后
+
+### 8.2 备注额度
+
+> 免费版最多可保存 3 个应用备注。解锁 Pro 后,可为所有应用保存备注。
+
+### 8.3 自定义标签颜色
+
+> 自定义标签颜色属于 Pro 功能。解锁 Pro 后,可为每个标签设置任意颜色,让分类更容易识别。
+
+### 8.4 手动排序持久化
+
+> 当前仅为预览。解锁 Pro 后才可保存手动排序。
+
+## 9. 更新记录
+
+| 日期 | 变更 |
+|:---|:---|
+| 2026-07-01 | 新增免费 / Pro 功能对比备忘。 |
+| 2026-07-01 | 记录自定义标签额度方案:免费 6 个自定义维护容器,Pro 不限。 |
+| 2026-07-01 | 记录默认智能分类边界:默认可用,个性化维护后计入额度。 |
diff --git a/src/Scripts/pro_custom_container_quota_qa.sh b/src/Scripts/pro_custom_container_quota_qa.sh
new file mode 100755
index 0000000..7c7225f
--- /dev/null
+++ b/src/Scripts/pro_custom_container_quota_qa.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+python3 - "$ROOT_DIR" <<'PY'
+import json
+import sys
+from pathlib import Path
+
+root = Path(sys.argv[1])
+app_dir = root / "Apptag"
+localization_dir = app_dir / "Localization"
+
+
+def fail(message: str) -> None:
+ raise SystemExit(f"FAIL: {message}")
+
+
+def read(name: str) -> str:
+ return (app_dir / name).read_text(encoding="utf-8")
+
+
+def require(source: str, needle: str, message: str) -> None:
+ if needle not in source:
+ fail(message)
+
+
+pro = read("ProEntitlement.swift")
+data_layer = read("DataLayer.swift")
+tag_editor = read("TagEditorView.swift")
+content = read("ContentView.swift")
+preferences = read("PreferencesView.swift")
+access = read("ProAccessViews.swift")
+
+require(pro, "case customContainerQuota", "ProFeature.customContainerQuota is missing")
+require(pro, "static let freeCustomContainerLimit = 6", "free custom container limit must be 6")
+require(pro, "struct ProCustomContainerQuotaStatus", "custom container quota status model is missing")
+require(pro, "customContainerMaintenanceDecision", "custom container maintenance decision helper is missing")
+require(pro, "TagDatabase.customMaintainedContainerCount", "quota must count through TagDatabase")
+require(access, "case .customContainerQuota:", "custom container quota prompt key is missing")
+
+require(data_layer, "var isCustomMaintained: Bool? = nil", "TagDef must store an optional custom-maintenance marker for old-data compatibility")
+require(data_layer, "static func customMaintainedContainerCount", "TagDatabase custom container count helper is missing")
+require(data_layer, "guard let systemCategoryID = definition.systemCategoryID else", "user-created tags must count as custom maintained containers")
+require(data_layer, "definition.color != systemCategoryID.defaultColorIndex", "system tags with changed base color must count")
+require(data_layer, "definition.customColor != nil", "system tags with custom color must count")
+require(data_layer, "markCustomMaintainedIfNeeded", "system tag manual maintenance marker is missing")
+require(data_layer, "static func createTag(_ name: String, color: Int) -> TagEditorMutationResult", "createTag must return a mutation result")
+require(data_layer, "customContainerMaintenanceResult(for: [name], in: store)", "createTag must check free quota before saving")
+require(data_layer, "static func renameTag(from oldName: String, to newName: String) -> TagEditorMutationResult", "renameTag must return a mutation result")
+require(data_layer, "def.isCustomMaintained = true", "renamed system tags must become custom maintained")
+require(data_layer, "static func setColor(_ color: Int, for tag: String) -> TagEditorMutationResult", "setColor must return a mutation result")
+require(data_layer, "willBeCustomMaintained", "setColor must check whether a system tag becomes custom maintained")
+require(data_layer, "static func appendTags(_ tags: [String], to paths: [String]) -> TagEditorMutationResult", "appendTags must return a mutation result")
+require(data_layer, "static func removeTags(_ tags: [String], from paths: [String]) -> TagEditorMutationResult", "removeTags must return a mutation result")
+require(data_layer, "static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) -> TagEditorMutationResult", "moveApp must return a mutation result")
+require(data_layer, "TagDatabase.markCustomMaintainedIfNeeded", "membership changes must mark maintained system tags")
+require(data_layer, "static func reorderTags(_ names: [String]) -> Bool", "tag order persistence must return whether it saved")
+require(data_layer, "guard ProEntitlementPolicy.isUnlocked(.persistentAppSorting) else { return false }", "tag order persistence must be Pro-gated")
+
+require(tag_editor, "onCustomContainerQuotaExceeded", "TagEditorView must expose quota-blocked callback")
+require(tag_editor, "onCustomContainerQuotaStatusChanged", "TagEditorView must expose quota status refresh callback")
+require(tag_editor, "onCustomContainerQuotaStatusChanged(ProEntitlementPolicy.customContainerQuotaStatus())", "TagEditorView must refresh quota status after successful mutations")
+require(tag_editor, "onCustomContainerQuotaStatusChanged(status)", "TagEditorView must surface blocked quota status to the header")
+require(tag_editor, "guard handleMutationResult(result) else { return }", "TagEditorView must not update local state before a save succeeds")
+require(tag_editor, "blockedCustomContainerQuota", "TagEditorView must surface quota blocks")
+
+require(content, "persistTagOrderIfAllowed()", "ContentView must route tag order save through a Pro gate")
+require(content, "pro.tagSorting.previewToast", "free tag sorting preview toast is missing")
+require(content, "presentCustomContainerQuotaPrompt", "ContentView must show custom container quota prompt")
+require(content, "customContainerQuotaAccessory", "ContentView tag editor must show quota count")
+require(content, "@State private var customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus()", "ContentView quota count must be backed by SwiftUI state")
+require(content, "let status = effectiveCustomContainerQuotaStatus", "ContentView quota accessory must read state-backed effective status")
+require(content, "onCustomContainerQuotaStatusChanged: refreshCustomContainerQuotaStatus", "ContentView must refresh quota count after TagEditorView saves")
+require(content, "refreshCustomContainerQuotaStatus(in: store)", "ContentView must initialize quota count from the edit-mode store")
+require(content, "pro.customContainers.manageExisting", "quota prompt secondary action must manage existing tags")
+require(content, "handleTagMutationResult(result)", "ContentView membership mutations must respect quota blocks")
+
+require(preferences, "presentSettingsCustomContainerQuotaPrompt", "Settings tag page must show quota prompt")
+require(preferences, "customContainerQuotaAccessory", "Settings tag page must show quota count")
+require(preferences, "@State private var settingsCustomContainerQuotaOverviewStatus = ProEntitlementPolicy.customContainerQuotaStatus()", "Settings quota count must be backed by SwiftUI state")
+require(preferences, "let status = effectiveCustomContainerQuotaStatus", "Settings quota accessory must read state-backed effective status")
+require(preferences, "onCustomContainerQuotaStatusChanged: refreshSettingsCustomContainerQuotaStatus", "Settings must refresh quota count after TagEditorView saves")
+require(preferences, "settingsCustomContainerQuotaOverviewStatus = quotaStatus", "Settings scanApps must initialize quota count from the same store")
+require(preferences, 'title: tr("pro.feature.maintainableTagCount")', "Pro comparison must include maintainable tag count row")
+require(preferences, 'title: tr("pro.feature.tagOrderPersistence")', "Pro comparison must include tag order row")
+require(preferences, 'title: tr("pro.feature.containerAppOrderPersistence")', "Pro comparison must include container app order row")
+
+first_three = [
+ preferences.find('title: tr("pro.feature.maintainableTagCount")'),
+ preferences.find('title: tr("pro.feature.tagOrderPersistence")'),
+ preferences.find('title: tr("pro.feature.containerAppOrderPersistence")'),
+]
+custom_color_index = preferences.find('title: tr("pro.feature.customTagColors")')
+if any(index == -1 for index in first_three) or custom_color_index == -1:
+ fail("could not locate Pro comparison feature rows")
+if not (first_three[0] < first_three[1] < first_three[2] < custom_color_index):
+ fail("new Pro comparison rows must be the first three rows")
+
+required_l10n = [
+ "pro.feature.customContainerQuota",
+ "pro.feature.customContainerQuota.benefit",
+ "pro.prompt.customContainerQuota",
+ "pro.customContainers.quotaShort",
+ "pro.customContainers.quotaUnlimitedShort",
+ "pro.customContainers.quotaPrompt",
+ "pro.customContainers.manageExisting",
+ "pro.tagSorting.previewToast",
+ "pro.feature.maintainableTagCount",
+ "pro.feature.tagOrderPersistence",
+ "pro.feature.containerAppOrderPersistence",
+ "settings.proStatus.maxSixTags",
+ "settings.proStatus.unlimitedTags",
+ "settings.proStatus.previewOnly",
+ "settings.proStatus.supported",
+]
+
+files = sorted(localization_dir.glob("*.json"))
+if len(files) != 29:
+ fail(f"expected 29 localization files, found {len(files)}")
+
+english = json.loads((localization_dir / "en.json").read_text(encoding="utf-8"))
+for path in files:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ missing = [key for key in required_l10n if not data.get(key)]
+ if missing:
+ fail(f"{path.name} missing keys: {', '.join(missing)}")
+ if not data.get("settings.proStatus.unlimitedTags", "").startswith("✅"):
+ fail(f"{path.name} unlimited tag status must include the leading checkmark")
+ if path.name != "en.json":
+ copied = [key for key in required_l10n if data.get(key) == english.get(key)]
+ if copied:
+ fail(f"{path.name} copies English quota text: {', '.join(copied)}")
+
+zh_hans = json.loads((localization_dir / "zh-Hans.json").read_text(encoding="utf-8"))
+if zh_hans.get("pro.customContainers.quotaShort") != "免费用户可以使用6个自定义标签(容器):已使用 %used% / %limit%":
+ fail("zh-Hans custom container quota line must use the approved free-user wording")
+if zh_hans.get("settings.proStatus.unlimitedTags") != "✅没有限制":
+ fail("zh-Hans unlimited tag status must include the approved leading checkmark")
+
+print("PASS Pro custom container quota QA: quota gate, tag-order preview, Pro table rows, and 29-language keys are wired")
+PY
diff --git a/src/Scripts/pro_custom_hotkeys_qa.sh b/src/Scripts/pro_custom_hotkeys_qa.sh
index a70de65..23baf5f 100755
--- a/src/Scripts/pro_custom_hotkeys_qa.sh
+++ b/src/Scripts/pro_custom_hotkeys_qa.sh
@@ -222,7 +222,7 @@
require(hotkey_button, ".buttonStyle(.bordered)", "locked custom hotkey action must visually match Data tab Pro-gated buttons")
require(pro, "case customHotkeys", "ProFeature.customHotkeys is missing")
-require(pro, "case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .unlimitedNotes, .persistentAppSorting, .customHotkeys:", "customHotkeys must be unlocked only by the Pro access state")
+require(pro, "case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .customContainerQuota, .unlimitedNotes, .persistentAppSorting, .customHotkeys:", "customHotkeys must be unlocked only by the Pro access state")
require(pro, "NotificationCenter.default.post(name: .tagLauncherProEntitlementChanged", "entitlement changes must notify hotkey registration")
require(access_views, "case .customHotkeys:", "Pro prompt routing must include custom hotkeys")
diff --git a/src/Scripts/pro_feature_gate_qa.sh b/src/Scripts/pro_feature_gate_qa.sh
index 14186dd..b9d419f 100755
--- a/src/Scripts/pro_feature_gate_qa.sh
+++ b/src/Scripts/pro_feature_gate_qa.sh
@@ -61,6 +61,7 @@
"layoutImport",
"layoutExport",
"customTagColors",
+ "customContainerQuota",
"unlimitedNotes",
"persistentAppSorting",
"customHotkeys",
@@ -85,7 +86,7 @@
require(pro, "ProEntitlementSnapshotStore", "sync entitlement snapshot for data-layer policy is missing")
require(pro, "manualNonEmptyNoteCount", "note quota must count existing manual notes")
require(pro, "manualNonEmptyNotePaths", "note quota must avoid double-counting existing notes")
-require(pro, "case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .unlimitedNotes, .persistentAppSorting, .customHotkeys:", "custom tag colors and custom hotkeys must be unlocked only by the Pro access state")
+require(pro, "case .premiumThemes, .layoutImport, .layoutExport, .customTagColors, .customContainerQuota, .unlimitedNotes, .persistentAppSorting, .customHotkeys:", "custom tag colors, custom container quota, and custom hotkeys must be unlocked only by the Pro access state")
require(pro, "NotificationCenter.default.post(name: .tagLauncherProEntitlementChanged", "Pro entitlement changes must notify hotkey registration")
effective_theme = function_body(pro, "static func effectiveTheme(for storedTheme: AppGridTheme) -> AppGridTheme")
@@ -248,15 +249,36 @@
if 'Text(tr("settings.language"))' in preferences:
fail("Language tab must not render the removed header/title block")
require(preferences, "centeredSettingsScrollContent<Content: View>", "settings pages with sparse content must use the shared global centering container")
-require(preferences, "centeredSettingsScrollContent(width: settingsContentWidth)", "language picker matrix must use the shared centered settings container")
+require(preferences, "private let languageContentWidth: CGFloat = 820", "language picker matrix must have its own centered width aligned with fixed settings panels")
+require(preferences, "centeredSettingsScrollContent(width: languageContentWidth)", "language picker matrix must use the centered settings container with the language-specific width")
if preferences.count("centeredSettingsScrollContent(width: dataPanelWidth)") < 2:
fail("Pro and About pages must both use the shared centered settings container")
require(preferences, ".frame(minHeight: proxy.size.height, alignment: .center)", "centered settings content must be vertically centered in the visible area")
-if "topLeadingAccessory: AnyView(" in preferences:
- fail("Tags page must not render a second Pro guidance row inside the tag editor")
+if "topLeadingAccessory: AnyView(settingsProStatusOverview" in preferences:
+ fail("Tags page must not render a second bulky Pro guidance row inside the tag editor")
if preferences.count("ScrollView(.vertical, showsIndicators: true)") < 3:
fail("hotkeys/data/about pages must use vertical scroll containers to avoid clipping")
require(preferences, "NSEvent.addLocalMonitorForEvents(matching: .keyDown)", "custom hotkey recording must be local to Settings")
+require(preferences, "settingsEscapeMonitor", "Settings must own a local Escape monitor for overlay presentation")
+require(preferences, "installSettingsEscapeMonitor()", "Settings Escape monitor must be installed when Preferences appears")
+require(preferences, "removeSettingsEscapeMonitor()", "Settings Escape monitor must be removed when Preferences disappears")
+settings_escape_body = function_body(preferences, "private func handleSettingsEscapeKey() -> Bool")
+for needle, message in [
+ ("guard !isDataFilePanelPresented, window.attachedSheet == nil else { return false }", "Settings Escape must let file panels/sheets handle Escape first"),
+ ("dismissSettingsProPrompt()", "Settings Escape must close the in-window Pro prompt before closing Settings"),
+ ("showApplySystemSchemeConfirmation = false", "Settings Escape must close apply-confirmation overlays before closing Settings"),
+ ("showResetToUncategorizedConfirmation = false", "Settings Escape must close reset-confirmation overlays before closing Settings"),
+ ("stopHotkeyRecording(showCancelledToast: true)", "Settings Escape must cancel hotkey recording before closing Settings"),
+ ("window.performClose(nil)", "Settings Escape must close the Settings window in the normal overlay case"),
+]:
+ require(settings_escape_body, needle, message)
+settings_escape_filter_body = function_body(preferences, "private func isSettingsOverlayEscapeEvent(_ event: NSEvent) -> Bool")
+for needle, message in [
+ ("let parent = window.parent as? OverlayPanel", "Settings Escape must be limited to Settings shown over the AppGrid overlay"),
+ ("parent.isVisible", "Settings Escape must require the overlay parent to be visible"),
+ ("eventWindow == window || eventWindow == parent", "Settings Escape must handle both Settings-key and overlay-key event routing"),
+]:
+ require(settings_escape_filter_body, needle, message)
require(preferences, "proEntitlement.startThemePreview(for: theme, tuning:", "premium theme free preview is missing")
require(preferences, "proEntitlement.stopThemePreview()", "theme preview cleanup is missing")
require(preferences, "proEntitlement.startDisplayModePreview(for: mode)", "colored display mode free preview is missing")
@@ -287,7 +309,8 @@
if "localizedCaseInsensitiveContains" in access_views:
fail("ProStatusPill must not hide the crown based on localized text content")
require(content, "onUnlock: { proEntitlement.purchasePro() }", "AppGrid Pro prompt is not wired to purchase action")
-require(content, "onRestore: { proEntitlement.restorePurchases() }", "AppGrid Pro prompt is not wired to restore action")
+require(content, "onRestore: { handleProPromptSecondaryAction(prompt) }", "AppGrid Pro prompt is not wired to secondary action handler")
+require(content, "proEntitlement.restorePurchases()", "AppGrid Pro prompt secondary handler must still support restore purchase")
tag_editor = read("TagEditorView.swift")
require(tag_editor, "ColorPicker(", "Pro custom tag colors must use a native macOS color picker path")
@@ -303,5 +326,5 @@
require(tag_nav, "let customColor: TagCustomColor?", "Tag navigation items must carry custom colors")
require(tag_nav, "TagColor.nsColor(for: item.colorIndex, customColor: item.customColor)", "Tag navigation must render custom colors")
-print("PASS Pro feature gate QA: StoreKit entitlement, legacy unlock, theme gating, import/export locks, 3-note quota, custom tag colors, custom hotkeys, sorting preview, and Pro UI prompts are wired")
+print("PASS Pro feature gate QA: StoreKit entitlement, legacy unlock, theme gating, import/export locks, 3-note quota, custom tag colors, custom container quota, custom hotkeys, sorting preview, and Pro UI prompts are wired")
PY
diff --git a/src/Scripts/pro_localization_qa.sh b/src/Scripts/pro_localization_qa.sh
index b33b450..b1a953c 100755
--- a/src/Scripts/pro_localization_qa.sh
+++ b/src/Scripts/pro_localization_qa.sh
@@ -22,6 +22,8 @@
REQUIRED_PLACEHOLDERS = {
"pro.notes.remaining": {"%limit%", "%remaining%"},
"pro.theme.status.previewCountdown": {"%time%"},
+ "pro.customContainers.quotaShort": {"%used%", "%limit%"},
+ "pro.customContainers.quotaPrompt": {"%used%", "%limit%", "%remaining%"},
}
REQUIRED_NON_PRO_KEYS = [
"settings.proStatus.freeUser",
@@ -36,6 +38,10 @@
"settings.proStatus.unsupportedFeature",
"settings.proStatus.unavailableFeature",
"settings.proStatus.previewFiveMinutes",
+ "settings.proStatus.maxSixTags",
+ "settings.proStatus.unlimitedTags",
+ "settings.proStatus.previewOnly",
+ "settings.proStatus.supported",
"settings.proFeature.appNotes",
"settings.proFeature.dataBackup",
"settings.proBenefit.general",
@@ -122,6 +128,12 @@
fail("en.json Pro comparison unlimited notes status must include the leading checkmark")
if english.get("settings.proStatus.unlockedFeature") != "✅ Unlocked":
fail("en.json Pro comparison unlocked status must be '✅ Unlocked'")
+if english.get("settings.proStatus.supported") != "✅ Supported":
+ fail("en.json Pro comparison supported status must be '✅ Supported'")
+if english.get("settings.proStatus.unlimitedTags") != "✅ No limit":
+ fail("en.json Pro comparison unlimited tag status must be '✅ No limit'")
+if english.get("pro.customContainers.quotaShort") != "Free users can use 6 custom tags (containers): %used% / %limit% used":
+ fail("en.json custom container quota line must use the approved free-user wording")
if english.get("settings.proStatus.proUser") != "You are currently a valued Pro user":
fail("en.json settings.proStatus.proUser must not duplicate the crown icon in text")
@@ -132,6 +144,12 @@
fail("zh-Hans.json Pro comparison unlimited notes status must be '✅无限备注'")
if zh_hans.get("settings.proStatus.unlockedFeature") != "✅已解锁":
fail("zh-Hans.json Pro comparison unlocked status must be '✅已解锁'")
+if zh_hans.get("settings.proStatus.supported") != "✅支持":
+ fail("zh-Hans.json Pro comparison supported status must be '✅支持'")
+if zh_hans.get("settings.proStatus.unlimitedTags") != "✅没有限制":
+ fail("zh-Hans.json Pro comparison unlimited tag status must be '✅没有限制'")
+if zh_hans.get("pro.customContainers.quotaShort") != "免费用户可以使用6个自定义标签(容器):已使用 %used% / %limit%":
+ fail("zh-Hans.json custom container quota line must use the approved free-user wording")
if zh_hans.get("settings.proStatus.proUser") != "您当前是尊贵的 Pro 用户":
fail("zh-Hans.json settings.proStatus.proUser must be the approved centered Pro identity sentence")
@@ -203,6 +221,8 @@
if "👑" in data.get("settings.proStatus.proUser", ""):
fail(f"{code}.json settings.proStatus.proUser must not duplicate the crown icon")
+ if not data.get("settings.proStatus.unlimitedTags", "").startswith("✅"):
+ fail(f"{code}.json settings.proStatus.unlimitedTags must include the leading checkmark")
print(
"PASS Pro localization QA: "
diff --git a/src/Scripts/settings_language_title_qa.sh b/src/Scripts/settings_language_title_qa.sh
new file mode 100755
index 0000000..e3946e5
--- /dev/null
+++ b/src/Scripts/settings_language_title_qa.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+python3 - "$ROOT_DIR" <<'PY'
+import re
+import sys
+from pathlib import Path
+
+root = Path(sys.argv[1])
+app_dir = root / "Apptag"
+preferences = (app_dir / "PreferencesView.swift").read_text(encoding="utf-8")
+app = (app_dir / "ApptagApp.swift").read_text(encoding="utf-8")
+
+
+def fail(message: str) -> None:
+ raise SystemExit(f"FAIL: {message}")
+
+
+def require(source: str, needle: str, message: str) -> None:
+ if needle not in source:
+ fail(message)
+
+
+require(
+ app,
+ 'window.title = tr("menu.preferences").replacingOccurrences(of: "…", with: "")',
+ "Settings window must use the localized preferences title at creation",
+)
+require(
+ preferences,
+ "private var localizedPreferencesWindowTitle: String",
+ "PreferencesView must have a single localized title source",
+)
+require(
+ preferences,
+ 'tr("menu.preferences").replacingOccurrences(of: "…", with: "")',
+ "PreferencesView localized title must use menu.preferences and strip ellipsis",
+)
+require(
+ preferences,
+ "private func refreshPreferencesWindowTitle()",
+ "PreferencesView must expose a window-title refresh helper",
+)
+require(
+ preferences,
+ "preferencesWindow?.title = localizedPreferencesWindowTitle",
+ "PreferencesView must write the localized title back to NSWindow.title",
+)
+require(
+ preferences,
+ "return window.title == localizedPreferencesWindowTitle",
+ "Preferences window fallback detection must reuse the localized title source",
+)
+
+language_marker = ".onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange))"
+language_start = preferences.find(language_marker)
+if language_start == -1:
+ fail("PreferencesView must observe appLanguageDidChange")
+language_end = preferences.find(".onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange))", language_start)
+if language_end == -1:
+ fail("PreferencesView appLanguageDidChange handler must be followed by tagLauncherDataDidChange handler")
+language_block = preferences[language_start:language_end]
+if "refreshPreferencesWindowTitle()" not in language_block:
+ fail("appLanguageDidChange handler must refresh NSWindow.title")
+
+root_frame_marker = ".frame(width: settingsWindowWidth, height: settingsWindowHeight)"
+root_frame_start = preferences.find(root_frame_marker)
+if root_frame_start == -1:
+ fail("PreferencesView root frame marker is missing")
+appear_marker = ".onAppear {"
+appear_start = preferences.find(appear_marker, root_frame_start)
+if appear_start == -1:
+ fail("PreferencesView must have an onAppear handler")
+appear_end = preferences.find(".onDisappear", appear_start)
+if appear_end == -1:
+ fail("PreferencesView root onAppear must be followed by onDisappear")
+appear_block = preferences[appear_start:appear_end]
+if "refreshPreferencesWindowTitle()" not in appear_block:
+ fail("PreferencesView.onAppear must refresh NSWindow.title for restored windows")
+
+if "window.title == tr(\"menu.preferences\")" in preferences:
+ fail("PreferencesView must not duplicate raw localized title comparisons")
+
+print("PASS Settings language title QA: preferences window title refreshes on appear and language changes")
+PY
diff --git a/src/Scripts/tag_editor_mouse_event_qa.sh b/src/Scripts/tag_editor_mouse_event_qa.sh
new file mode 100755
index 0000000..6f9a4bf
--- /dev/null
+++ b/src/Scripts/tag_editor_mouse_event_qa.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+python3 - "$ROOT_DIR" <<'PY'
+import re
+import sys
+from pathlib import Path
+
+root = Path(sys.argv[1])
+app_dir = root / "Apptag"
+
+
+def fail(message: str) -> None:
+ raise SystemExit(f"FAIL: {message}")
+
+
+content = (app_dir / "ContentView.swift").read_text(encoding="utf-8")
+app = (app_dir / "ApptagApp.swift").read_text(encoding="utf-8")
+
+if "func focusTextField()" not in content:
+ fail("TextFieldContainer must expose a focus-only path")
+if "private var isEditing: Bool" not in content:
+ fail("TextFieldContainer must track whether the text field is already editing")
+if "return textField.hitTest(fieldPoint) ?? textField" not in content:
+ fail("TextFieldContainer must let active editors handle native caret placement")
+
+container_match = re.search(
+ r"final\s+class\s+TextFieldContainer:\s+NSView\s*\{(?P<body>.*?)\n\}",
+ content,
+ re.S,
+)
+if not container_match:
+ fail("TextFieldContainer class is missing")
+
+container_body = container_match.group("body")
+mouse_down_match = re.search(
+ r"override\s+func\s+mouseDown\s*\([^)]*\)\s*\{(?P<body>.*?)\n\s*\}",
+ container_body,
+ re.S,
+)
+if not mouse_down_match:
+ fail("TextFieldContainer.mouseDown implementation is missing")
+
+mouse_down_body = mouse_down_match.group("body")
+if "textField.mouseDown(with: event)" in mouse_down_body:
+ fail("TextFieldContainer.mouseDown must not forward the same event to NSTextField.mouseDown")
+if "focusTextField()" not in mouse_down_body:
+ fail("TextFieldContainer.mouseDown must focus the text field")
+
+if "container.mouseDown(with: event)" in app:
+ fail("Overlay hosting view must not manually forward mouseDown to TextFieldContainer")
+if "tf.mouseDown(with: event)" in app:
+ fail("Overlay hosting view must not manually forward mouseDown to NSTextField")
+if "container.focusTextField()" not in app:
+ fail("Overlay hosting view must use the focus-only text field path")
+if "let shouldSelectAll = tf.currentEditor() == nil" not in app:
+ fail("Overlay hosting view must not reselect all text while an editor is active")
+
+for path in app_dir.glob("*.swift"):
+ source = path.read_text(encoding="utf-8")
+ forbidden = [
+ ".mouseDown(with: event)",
+ ".mouseDown(with:event)",
+ ]
+ for needle in forbidden:
+ for match in re.finditer(re.escape(needle), source):
+ line_start = source.rfind("\n", 0, match.start()) + 1
+ line_end = source.find("\n", match.start())
+ if line_end == -1:
+ line_end = len(source)
+ line = source[line_start:line_end].strip()
+ if line.startswith("super."):
+ continue
+ if "floatingButton.mouseDown(with: event)" in line:
+ continue
+ fail(f"{path.name} must not manually forward the same mouseDown event: {line}")
+
+print("PASS Tag editor mouse event QA: text-field clicks focus without recursive mouseDown forwarding")
+PY
--
Gitblit v1.9.3