587 lines
22 KiB
Swift
587 lines
22 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
struct ProviderCostContent: View {
|
|
let section: UsageMenuCardView.Model.ProviderCostSection
|
|
let progressColor: Color
|
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
|
|
|
var body: some View {
|
|
if self.section.presentation == .inlineValue {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(self.section.title)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
Spacer()
|
|
Text(self.section.spendLine)
|
|
.font(.footnote)
|
|
.monospacedDigit()
|
|
.lineLimit(1)
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(self.section.title)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
if let balanceLine = self.section.balanceLine {
|
|
Spacer(minLength: 8)
|
|
Text(balanceLine)
|
|
.font(.footnote)
|
|
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
|
.monospacedDigit()
|
|
.lineLimit(1)
|
|
.layoutPriority(1)
|
|
}
|
|
}
|
|
if let percentUsed = self.section.percentUsed {
|
|
UsageProgressBar(
|
|
percent: percentUsed,
|
|
tint: self.progressColor,
|
|
accessibilityLabel: L("Extra usage spent"))
|
|
}
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(self.section.spendLine).font(.footnote).lineLimit(1)
|
|
Spacer()
|
|
if let percentLine = self.section.percentLine {
|
|
Text(percentLine)
|
|
.font(.footnote)
|
|
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
if let personalSpendLine = self.section.personalSpendLine {
|
|
Text(personalSpendLine)
|
|
.font(.footnote).foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)).lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension UsageMenuCardView.Model.ProviderCostSection {
|
|
enum Presentation: Equatable {
|
|
case detail
|
|
case inlineValue
|
|
}
|
|
|
|
init(
|
|
title: String,
|
|
percentUsed: Double?,
|
|
spendLine: String,
|
|
percentLine: String?,
|
|
balanceLine: String? = nil,
|
|
presentation: Presentation = .detail,
|
|
showsInProviderDetails: Bool = true)
|
|
{
|
|
self.init(
|
|
title: title,
|
|
percentUsed: percentUsed,
|
|
spendLine: spendLine,
|
|
percentLine: percentLine,
|
|
balanceLine: balanceLine,
|
|
personalSpendLine: nil,
|
|
presentation: presentation,
|
|
showsInProviderDetails: showsInProviderDetails)
|
|
}
|
|
}
|
|
|
|
extension UsageMenuCardView.Model {
|
|
static func providerCostFollowsSummaryStyle(
|
|
cost: ProviderCostSnapshot?,
|
|
style: ProviderCostMenuCardStyle,
|
|
isClaudeAdminAPI: Bool) -> Bool
|
|
{
|
|
// Provider-specific by design: Claude Admin API spend is a summary, while Claude extra-usage balance is not.
|
|
switch style {
|
|
case .apiSpend, .payAsYouGoSpend:
|
|
true
|
|
case .claude:
|
|
isClaudeAdminAPI
|
|
case .clawRouter:
|
|
(cost?.limit ?? 0) <= 0
|
|
case .generic, .hidden, .extraUsageBalance, .zenBalance, .pointsBalance, .prepaidCredits,
|
|
.payAsYouGoBalance:
|
|
false
|
|
}
|
|
}
|
|
|
|
static func isRequiredOpenCodeZenBalance(_ snapshot: UsageSnapshot?) -> Bool {
|
|
snapshot?.primary == nil &&
|
|
snapshot?.secondary == nil &&
|
|
snapshot?.providerCost?.period == "Zen balance"
|
|
}
|
|
|
|
static func tokenUsageSnapshot(input: Input) -> CostUsageTokenSnapshot? {
|
|
if usesProviderCostHistoryAsPrimaryDashboard(input.provider), input.snapshot != nil {
|
|
return primaryCostHistorySnapshot(input: input)
|
|
}
|
|
return input.tokenSnapshot
|
|
}
|
|
|
|
static func creditsLine(
|
|
metadata: ProviderMetadata,
|
|
snapshot: UsageSnapshot?,
|
|
credits: CreditsSnapshot?,
|
|
error: String?,
|
|
preferredCurrencyCode: String = "auto") -> String?
|
|
{
|
|
guard metadata.supportsCredits else { return nil }
|
|
let visibility = ProviderDescriptorRegistry.descriptor(for: metadata.id).presentation.menuCard.creditsVisibility
|
|
if visibility == .hidden ||
|
|
(visibility == .requiresValueOrError && credits == nil && error == nil) ||
|
|
(visibility == .hiddenWhenUsageSnapshotPresent && snapshot != nil)
|
|
{
|
|
return nil
|
|
}
|
|
if let credits {
|
|
if let creditLimit = credits.codexCreditLimit {
|
|
return UsageFormatter.creditsString(from: creditLimit.remaining)
|
|
}
|
|
return UsageFormatter.creditsString(from: credits.remaining)
|
|
}
|
|
if let error, !error.isEmpty {
|
|
return error.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
return L(metadata.creditsHint)
|
|
}
|
|
|
|
static func creditsProgressPercent(credits: CreditsSnapshot?) -> Double? {
|
|
credits?.codexCreditLimit?.remainingPercent
|
|
}
|
|
|
|
static func creditsScaleText(credits: CreditsSnapshot?) -> String? {
|
|
guard let limit = credits?.codexCreditLimit else { return nil }
|
|
return L("of %@", UsageFormatter.creditsNumberString(from: limit.limit))
|
|
}
|
|
|
|
static func codexCreditLimitDetail(credits: CreditsSnapshot?, now: Date) -> String? {
|
|
guard let limit = credits?.codexCreditLimit else { return nil }
|
|
var parts = [
|
|
L("%@ used", UsageFormatter.creditsNumberString(from: limit.used)),
|
|
]
|
|
if let resetsAt = limit.resetsAt {
|
|
parts.append(L("resets %@", UsageFormatter.resetDescription(from: resetsAt, now: now)))
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
static func tokenUsageSection(
|
|
provider: UsageProvider,
|
|
enabled: Bool,
|
|
isRefreshing: Bool = false,
|
|
comparisonPeriodsEnabled: Bool,
|
|
snapshot: CostUsageTokenSnapshot?,
|
|
error: String?,
|
|
preferredCurrencyCode: String = "auto") -> TokenUsageSection?
|
|
{
|
|
guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else {
|
|
return nil
|
|
}
|
|
guard enabled else { return nil }
|
|
guard let snapshot else { return nil }
|
|
|
|
let sessionCost = snapshot.sessionCostUSD.map {
|
|
UsageFormatter.convertedCostString(
|
|
$0,
|
|
preferredCurrency: preferredCurrencyCode,
|
|
providerCurrency: snapshot.currencyCode)
|
|
} ?? "—"
|
|
let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
|
|
let sessionLabel = if provider == .bedrock || provider == .mistral {
|
|
Self.latestBillingDayLabel(from: snapshot)
|
|
} else {
|
|
L("Today")
|
|
}
|
|
let sessionLine: String = {
|
|
if let sessionTokens {
|
|
return String(format: L("%@: %@ · %@ tokens"), sessionLabel, sessionCost, sessionTokens)
|
|
}
|
|
return "\(sessionLabel): \(sessionCost)"
|
|
}()
|
|
|
|
let monthCost = snapshot.last30DaysCostUSD.map {
|
|
UsageFormatter.convertedCostString(
|
|
$0,
|
|
preferredCurrency: preferredCurrencyCode,
|
|
providerCurrency: snapshot.currencyCode)
|
|
} ?? "—"
|
|
let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +)
|
|
let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil)
|
|
let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) }
|
|
let windowLabel = if let historyLabel = snapshot.historyLabel {
|
|
historyLabel
|
|
} else if provider == .mistral,
|
|
snapshot.historyDays == 1,
|
|
Self.bedrockLatestBillingDay(from: snapshot.daily) != nil
|
|
{
|
|
L("Latest billing day")
|
|
} else {
|
|
Self.costHistoryWindowLabel(days: snapshot.historyDays)
|
|
}
|
|
let monthLine: String = {
|
|
if let monthTokens {
|
|
return String(format: L("%@: %@ · %@ tokens"), windowLabel, monthCost, monthTokens)
|
|
}
|
|
return "\(windowLabel): \(monthCost)"
|
|
}()
|
|
// Plan-metered spend over the same window (what the provider actually deducts);
|
|
// only providers that report it (currently Cursor) populate `meteredCostUSD`.
|
|
let meteredLine: String? = snapshot.meteredCostUSD.map {
|
|
let amount = UsageFormatter.convertedCostString(
|
|
$0,
|
|
preferredCurrency: preferredCurrencyCode,
|
|
providerCurrency: snapshot.currencyCode)
|
|
return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased())
|
|
}
|
|
let err = (error?.isEmpty ?? true) ? nil : error
|
|
return TokenUsageSection(
|
|
isRefreshing: isRefreshing,
|
|
sessionLine: sessionLine,
|
|
monthLine: monthLine,
|
|
meteredLine: meteredLine,
|
|
comparisonLines: comparisonPeriodsEnabled
|
|
? snapshot.comparisonSummaries().map {
|
|
Self.costWindowLine(
|
|
summary: $0,
|
|
currencyCode: UsageFormatter.effectiveCurrencyCode(
|
|
preferred: preferredCurrencyCode,
|
|
providerCurrency: snapshot.currencyCode),
|
|
sourceCurrencyCode: snapshot.currencyCode)
|
|
}
|
|
: [],
|
|
hintLine: Self.tokenUsageHint(provider: provider),
|
|
errorLine: err,
|
|
errorCopyText: (error?.isEmpty ?? true) ? nil : error)
|
|
}
|
|
|
|
static func costWindowLine(
|
|
summary: CostUsageWindowSummary,
|
|
currencyCode: String,
|
|
sourceCurrencyCode: String? = nil) -> String
|
|
{
|
|
let label = Self.costHistoryWindowLabel(days: summary.days)
|
|
let cost = summary.totalCostUSD.map {
|
|
UsageFormatter.convertedCostString(
|
|
$0,
|
|
preferredCurrency: currencyCode,
|
|
providerCurrency: sourceCurrencyCode ?? currencyCode)
|
|
} ?? "—"
|
|
guard let totalTokens = summary.totalTokens else { return "\(label): \(cost)" }
|
|
return String(
|
|
format: L("%@: %@ · %@ tokens"),
|
|
label,
|
|
cost,
|
|
UsageFormatter.tokenCountString(totalTokens))
|
|
}
|
|
|
|
static func tokenUsageHint(provider: UsageProvider) -> String? {
|
|
let lines = Self.tokenUsageHintLines(provider: provider)
|
|
return lines.isEmpty ? nil : lines.joined(separator: "\n")
|
|
}
|
|
|
|
static func tokenUsageHeader(provider _: UsageProvider) -> String {
|
|
L("Cost")
|
|
}
|
|
|
|
static func tokenUsageHintLines(provider: UsageProvider) -> [String] {
|
|
ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.menuHintLines.map { hint in
|
|
switch hint {
|
|
case let .localized(key): L(key)
|
|
case .estimate: UsageFormatter.costEstimateHint(provider: provider)
|
|
case let .literal(text): L(text)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func costHistoryWindowLabel(days: Int) -> String {
|
|
days == 1 ? L("Today") : String(format: L("Last %d days"), days)
|
|
}
|
|
|
|
private static func latestBillingDayLabel(from snapshot: CostUsageTokenSnapshot) -> String {
|
|
guard let entry = bedrockLatestBillingDay(from: snapshot.daily),
|
|
let displayDate = bedrockDisplayDate(from: entry.date)
|
|
else { return L("Latest billing day") }
|
|
return String(format: L("Latest billing day (%@)"), displayDate)
|
|
}
|
|
|
|
private static func bedrockLatestBillingDay(from entries: [CostUsageDailyReport.Entry])
|
|
-> CostUsageDailyReport.Entry?
|
|
{
|
|
entries.compactMap { entry -> (entry: CostUsageDailyReport.Entry, dayKey: String)? in
|
|
guard let dayKey = bedrockBillingDayKey(from: entry.date) else { return nil }
|
|
return (entry, dayKey)
|
|
}
|
|
.max { lhs, rhs in
|
|
if lhs.dayKey != rhs.dayKey {
|
|
return lhs.dayKey < rhs.dayKey
|
|
}
|
|
let lCost = lhs.entry.costUSD ?? -1
|
|
let rCost = rhs.entry.costUSD ?? -1
|
|
if lCost != rCost {
|
|
return lCost < rCost
|
|
}
|
|
let lTokens = lhs.entry.totalTokens ?? -1
|
|
let rTokens = rhs.entry.totalTokens ?? -1
|
|
if lTokens != rTokens {
|
|
return lTokens < rTokens
|
|
}
|
|
return lhs.entry.date < rhs.entry.date
|
|
}?.entry
|
|
}
|
|
|
|
private static func bedrockDisplayDate(from text: String) -> String? {
|
|
guard let dayKey = bedrockBillingDayKey(from: text) else { return nil }
|
|
let monthStart = dayKey.index(dayKey.startIndex, offsetBy: 5)
|
|
let monthEnd = dayKey.index(monthStart, offsetBy: 2)
|
|
let dayStart = dayKey.index(dayKey.startIndex, offsetBy: 8)
|
|
guard
|
|
let month = Int(dayKey[monthStart..<monthEnd]),
|
|
let day = Int(dayKey[dayStart...]),
|
|
(1...Self.bedrockMonthAbbreviations.count).contains(month),
|
|
(1...31).contains(day)
|
|
else { return nil }
|
|
return "\(Self.bedrockMonthAbbreviations[month - 1]) \(day)"
|
|
}
|
|
|
|
private static let bedrockMonthAbbreviations = [
|
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
]
|
|
|
|
private static func bedrockBillingDayKey(from text: String) -> String? {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.count == 10 else { return nil }
|
|
for (offset, character) in trimmed.enumerated() {
|
|
switch offset {
|
|
case 4, 7:
|
|
guard character == "-" else { return nil }
|
|
default:
|
|
guard character.isNumber else { return nil }
|
|
}
|
|
}
|
|
let monthStart = trimmed.index(trimmed.startIndex, offsetBy: 5)
|
|
let monthEnd = trimmed.index(monthStart, offsetBy: 2)
|
|
let dayStart = trimmed.index(trimmed.startIndex, offsetBy: 8)
|
|
let yearEnd = trimmed.index(trimmed.startIndex, offsetBy: 4)
|
|
guard
|
|
let year = Int(trimmed[..<yearEnd]),
|
|
let month = Int(trimmed[monthStart..<monthEnd]),
|
|
let day = Int(trimmed[dayStart...]),
|
|
(1...Self.bedrockMonthAbbreviations.count).contains(month),
|
|
(1...Self.daysInBedrockBillingMonth(month, year: year)).contains(day)
|
|
else { return nil }
|
|
return trimmed
|
|
}
|
|
|
|
private static func daysInBedrockBillingMonth(_ month: Int, year: Int) -> Int {
|
|
switch month {
|
|
case 2:
|
|
if year.isMultiple(of: 400) {
|
|
return 29
|
|
}
|
|
if year.isMultiple(of: 100) {
|
|
return 28
|
|
}
|
|
return year.isMultiple(of: 4) ? 29 : 28
|
|
case 4, 6, 9, 11:
|
|
return 30
|
|
default:
|
|
return 31
|
|
}
|
|
}
|
|
|
|
static func providerCostSection(
|
|
cost: ProviderCostSnapshot?,
|
|
style: ProviderCostMenuCardStyle,
|
|
isClaudeAdminAPI: Bool = false,
|
|
preferredCurrencyCode: String = "auto") -> ProviderCostSection?
|
|
{
|
|
guard style != .hidden else { return nil }
|
|
guard let cost else { return nil }
|
|
|
|
/// Formats a cost value using the user's currency preference.
|
|
func formatCost(_ value: Double, providerCurrency: String? = nil) -> String {
|
|
UsageFormatter.convertedCostString(
|
|
value,
|
|
preferredCurrency: preferredCurrencyCode,
|
|
providerCurrency: providerCurrency ?? cost.currencyCode)
|
|
}
|
|
|
|
if style == .extraUsageBalance {
|
|
let balance = formatCost(cost.used)
|
|
return ProviderCostSection(
|
|
title: L("Extra usage"),
|
|
percentUsed: nil,
|
|
spendLine: "\(L("Balance")): \(balance)",
|
|
percentLine: nil)
|
|
}
|
|
|
|
if style == .zenBalance {
|
|
let balance = formatCost(cost.used)
|
|
return ProviderCostSection(
|
|
title: L("Zen balance"),
|
|
percentUsed: nil,
|
|
spendLine: "\(L("Balance")): \(balance)",
|
|
percentLine: nil)
|
|
}
|
|
|
|
if style == .pointsBalance {
|
|
let balance = String(format: "%.0f", cost.used)
|
|
return ProviderCostSection(
|
|
title: L("Credits"),
|
|
percentUsed: nil,
|
|
spendLine: "\(L("Balance")): \(balance)",
|
|
percentLine: nil)
|
|
}
|
|
|
|
if style == .prepaidCredits {
|
|
let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
|
|
return ProviderCostSection(
|
|
title: L("Credits"),
|
|
percentUsed: nil,
|
|
spendLine: "\(L("Balance")): \(balance)",
|
|
percentLine: nil)
|
|
}
|
|
|
|
if style == .payAsYouGoSpend {
|
|
let spend = formatCost(cost.used)
|
|
let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month")
|
|
let balanceLine = cost.balance.map { "\(L("Balance")): \(formatCost($0))" }
|
|
return ProviderCostSection(
|
|
title: L("metric_mistral_payg"),
|
|
percentUsed: nil,
|
|
spendLine: "\(periodLabel): \(spend)",
|
|
percentLine: nil,
|
|
balanceLine: balanceLine)
|
|
}
|
|
|
|
if style == .payAsYouGoBalance {
|
|
let balance = formatCost(cost.used)
|
|
return ProviderCostSection(
|
|
title: L("metric_mistral_payg"),
|
|
percentUsed: nil,
|
|
spendLine: "\(L("Balance")): \(balance)",
|
|
percentLine: nil)
|
|
}
|
|
|
|
if style == .claude {
|
|
if isClaudeAdminAPI {
|
|
let spend = formatCost(cost.used)
|
|
let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days")
|
|
return ProviderCostSection(
|
|
title: L("API spend"),
|
|
percentUsed: nil,
|
|
spendLine: "\(periodLabel): \(spend)",
|
|
percentLine: nil)
|
|
}
|
|
|
|
if cost.limit <= 0 {
|
|
guard let balance = cost.balance else { return nil }
|
|
let value = formatCost(balance)
|
|
return ProviderCostSection(
|
|
title: L("Credits"),
|
|
percentUsed: nil,
|
|
spendLine: value,
|
|
percentLine: nil,
|
|
presentation: .inlineValue,
|
|
showsInProviderDetails: false)
|
|
}
|
|
|
|
let used = formatCost(cost.used)
|
|
let limit = formatCost(cost.limit)
|
|
let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
|
|
let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month")
|
|
let balanceLine = cost.balance.map {
|
|
"\(L("Balance")): \(formatCost($0))"
|
|
}
|
|
return ProviderCostSection(
|
|
title: L("Extra usage"),
|
|
percentUsed: percentUsed,
|
|
spendLine: "\(periodLabel): \(used) / \(limit)",
|
|
percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed))),
|
|
balanceLine: balanceLine,
|
|
showsInProviderDetails: false)
|
|
}
|
|
|
|
if style == .apiSpend {
|
|
let spend = formatCost(cost.used)
|
|
let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days")
|
|
return ProviderCostSection(
|
|
title: L("API spend"),
|
|
percentUsed: nil,
|
|
spendLine: "\(periodLabel): \(spend)",
|
|
percentLine: nil)
|
|
}
|
|
|
|
if style == .clawRouter, cost.limit <= 0 {
|
|
let spend = formatCost(cost.used)
|
|
return ProviderCostSection(
|
|
title: "ClawRouter spend",
|
|
percentUsed: nil,
|
|
spendLine: "\(L("This month")): \(spend)",
|
|
percentLine: nil)
|
|
}
|
|
|
|
guard cost.limit > 0 else { return nil }
|
|
|
|
let used: String
|
|
let limit: String
|
|
let title: String
|
|
|
|
if style == .clawRouter {
|
|
title = "Monthly budget"
|
|
used = formatCost(cost.used)
|
|
limit = formatCost(cost.limit)
|
|
} else if cost.currencyCode == "Quota" {
|
|
title = L("Quota usage")
|
|
used = String(format: "%.0f", cost.used)
|
|
limit = String(format: "%.0f", cost.limit)
|
|
} else {
|
|
title = L("Extra usage")
|
|
used = formatCost(cost.used)
|
|
limit = formatCost(cost.limit)
|
|
}
|
|
|
|
let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
|
|
let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month")
|
|
|
|
// When the headline budget is a shared pool (e.g. Cursor team on-demand), show the
|
|
// account's own contribution underneath it.
|
|
let personalSpendLine: String? = cost.personalUsed.flatMap { personal in
|
|
personal > 0
|
|
? "\(L("Your spend")): \(formatCost(personal))"
|
|
: nil
|
|
}
|
|
|
|
return ProviderCostSection(
|
|
title: title,
|
|
percentUsed: percentUsed,
|
|
spendLine: "\(periodLabel): \(used) / \(limit)",
|
|
percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed))),
|
|
balanceLine: nil,
|
|
personalSpendLine: personalSpendLine)
|
|
}
|
|
|
|
private static func localizedPeriodLabel(_ label: String) -> String {
|
|
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
switch trimmed.lowercased() {
|
|
case "last 30 days":
|
|
return L("Last 30 days")
|
|
case "this month":
|
|
return L("This month")
|
|
case "today":
|
|
return L("Today")
|
|
default:
|
|
return L(trimmed)
|
|
}
|
|
}
|
|
|
|
static func clamped(_ value: Double) -> Double {
|
|
min(100, max(0, value))
|
|
}
|
|
}
|