Skip to content

Commit 51909d6

Browse files
authored
Merge branch 'main' into feat/copy-as-sql-statements
Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
2 parents 22ccd83 + 70d9794 commit 51909d6

7 files changed

Lines changed: 267 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Copy as INSERT/UPDATE SQL statements from data grid context menu
13+
- Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour
14+
15+
### Fixed
16+
17+
- Plugin icon rendering now supports custom asset images (e.g., duckdb-icon) alongside SF Symbols in Installed and Browse tabs
1318

1419
## [0.17.0] - 2026-03-11
1520

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//
2+
// DownloadCountService.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
@MainActor @Observable
10+
final class DownloadCountService {
11+
static let shared = DownloadCountService()
12+
13+
private var counts: [String: Int] = [:]
14+
private static let logger = Logger(subsystem: "com.TablePro", category: "DownloadCountService")
15+
16+
private static let cacheKey = "downloadCountsCache"
17+
private static let cacheDateKey = "downloadCountsCacheDate"
18+
private static let cacheTTL: TimeInterval = 3_600 // 1 hour
19+
20+
// swiftlint:disable:next force_unwrapping
21+
private static let releasesURL = URL(string: "https://api.github.com/repos/datlechin/TablePro/releases?per_page=100")!
22+
23+
private let session: URLSession
24+
25+
private init() {
26+
let config = URLSessionConfiguration.default
27+
config.timeoutIntervalForRequest = 15
28+
config.timeoutIntervalForResource = 30
29+
self.session = URLSession(configuration: config)
30+
31+
loadCache()
32+
}
33+
34+
// MARK: - Public
35+
36+
func downloadCount(for pluginId: String) -> Int? {
37+
counts[pluginId]
38+
}
39+
40+
func fetchCounts(for manifest: RegistryManifest?) async {
41+
guard let manifest else { return }
42+
43+
if isCacheValid() {
44+
Self.logger.debug("Using cached download counts")
45+
return
46+
}
47+
48+
do {
49+
let releases = try await fetchReleases()
50+
let pluginReleases = releases.filter { $0.tagName.hasPrefix("plugin-") }
51+
let urlToPluginId = buildURLMap(from: manifest)
52+
53+
var totals: [String: Int] = [:]
54+
for release in pluginReleases {
55+
for asset in release.assets {
56+
if let pluginId = urlToPluginId[asset.browserDownloadUrl] {
57+
totals[pluginId, default: 0] += asset.downloadCount
58+
}
59+
}
60+
}
61+
62+
counts = totals
63+
saveCache(totals)
64+
Self.logger.info("Fetched download counts for \(totals.count) plugin(s)")
65+
} catch {
66+
Self.logger.error("Failed to fetch download counts: \(error.localizedDescription)")
67+
}
68+
}
69+
70+
// MARK: - GitHub API
71+
72+
private func fetchReleases() async throws -> [GitHubRelease] {
73+
var request = URLRequest(url: Self.releasesURL)
74+
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
75+
76+
let (data, response) = try await session.data(for: request)
77+
78+
guard let httpResponse = response as? HTTPURLResponse,
79+
(200...299).contains(httpResponse.statusCode) else {
80+
throw URLError(.badServerResponse)
81+
}
82+
83+
let decoder = JSONDecoder()
84+
decoder.keyDecodingStrategy = .convertFromSnakeCase
85+
return try decoder.decode([GitHubRelease].self, from: data)
86+
}
87+
88+
// MARK: - URL Mapping
89+
90+
private func buildURLMap(from manifest: RegistryManifest) -> [String: String] {
91+
var map: [String: String] = [:]
92+
for plugin in manifest.plugins {
93+
if let binaries = plugin.binaries {
94+
for binary in binaries {
95+
map[binary.downloadURL] = plugin.id
96+
}
97+
}
98+
if let url = plugin.downloadURL {
99+
map[url] = plugin.id
100+
}
101+
}
102+
return map
103+
}
104+
105+
// MARK: - Cache
106+
107+
private func isCacheValid() -> Bool {
108+
guard let cacheDate = UserDefaults.standard.object(forKey: Self.cacheDateKey) as? Date else {
109+
return false
110+
}
111+
return Date().timeIntervalSince(cacheDate) < Self.cacheTTL
112+
}
113+
114+
private func loadCache() {
115+
guard isCacheValid(),
116+
let data = UserDefaults.standard.data(forKey: Self.cacheKey),
117+
let cached = try? JSONDecoder().decode([String: Int].self, from: data) else {
118+
counts = [:]
119+
return
120+
}
121+
counts = cached
122+
}
123+
124+
private func saveCache(_ totals: [String: Int]) {
125+
if let data = try? JSONEncoder().encode(totals) {
126+
UserDefaults.standard.set(data, forKey: Self.cacheKey)
127+
UserDefaults.standard.set(Date(), forKey: Self.cacheDateKey)
128+
}
129+
}
130+
}
131+
132+
// MARK: - GitHub API Models
133+
134+
private struct GitHubRelease: Decodable {
135+
let tagName: String
136+
let assets: [GitHubAsset]
137+
}
138+
139+
private struct GitHubAsset: Decodable {
140+
let name: String
141+
let downloadCount: Int
142+
let browserDownloadUrl: String
143+
}

TablePro/Resources/Localizable.xcstrings

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,38 @@
305305
}
306306
}
307307
},
308+
"%@ download" : {
309+
"localizations" : {
310+
"vi" : {
311+
"stringUnit" : {
312+
"state" : "translated",
313+
"value" : "%@ lượt tải"
314+
}
315+
},
316+
"zh-Hans" : {
317+
"stringUnit" : {
318+
"state" : "translated",
319+
"value" : "%@ 次下载"
320+
}
321+
}
322+
}
323+
},
324+
"%@ downloads" : {
325+
"localizations" : {
326+
"vi" : {
327+
"stringUnit" : {
328+
"state" : "translated",
329+
"value" : "%@ lượt tải"
330+
}
331+
},
332+
"zh-Hans" : {
333+
"stringUnit" : {
334+
"state" : "translated",
335+
"value" : "%@ 次下载"
336+
}
337+
}
338+
}
339+
},
308340
"%@ is already assigned to \"%@\". Reassigning will remove it from that action." : {
309341
"localizations" : {
310342
"en" : {
@@ -2248,6 +2280,9 @@
22482280
}
22492281
}
22502282
}
2283+
},
2284+
"Auth Database" : {
2285+
22512286
},
22522287
"Authenticate to execute database operations" : {
22532288

@@ -5660,6 +5695,22 @@
56605695
}
56615696
}
56625697
},
5698+
"Downloads" : {
5699+
"localizations" : {
5700+
"vi" : {
5701+
"stringUnit" : {
5702+
"state" : "translated",
5703+
"value" : "Lượt tải"
5704+
}
5705+
},
5706+
"zh-Hans" : {
5707+
"stringUnit" : {
5708+
"state" : "translated",
5709+
"value" : "下载次数"
5710+
}
5711+
}
5712+
}
5713+
},
56635714
"Drop" : {
56645715
"extractionState" : "stale",
56655716
"localizations" : {

TablePro/Views/Settings/Plugins/BrowsePluginsView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ struct BrowsePluginsView: View {
99
private let registryClient = RegistryClient.shared
1010
private let pluginManager = PluginManager.shared
1111
private let installTracker = PluginInstallTracker.shared
12+
private let downloadCountService = DownloadCountService.shared
1213

1314
@State private var searchText = ""
1415
@State private var selectedCategory: RegistryCategory?
@@ -31,6 +32,7 @@ struct BrowsePluginsView: View {
3132
if registryClient.fetchState == .idle {
3233
await registryClient.fetchManifest()
3334
}
35+
await downloadCountService.fetchCounts(for: registryClient.manifest)
3436
}
3537
.alert("Installation Failed", isPresented: $showErrorAlert) {
3638
Button("OK") {}
@@ -117,6 +119,7 @@ struct BrowsePluginsView: View {
117119
plugin: plugin,
118120
isInstalled: isPluginInstalled(plugin.id),
119121
installProgress: installTracker.state(for: plugin.id),
122+
downloadCount: downloadCountService.downloadCount(for: plugin.id),
120123
onInstall: { installPlugin(plugin) },
121124
onToggleDetail: {
122125
withAnimation(.easeInOut(duration: 0.2)) {
@@ -130,6 +133,7 @@ struct BrowsePluginsView: View {
130133
plugin: plugin,
131134
isInstalled: isPluginInstalled(plugin.id),
132135
installProgress: installTracker.state(for: plugin.id),
136+
downloadCount: downloadCountService.downloadCount(for: plugin.id),
133137
onInstall: { installPlugin(plugin) }
134138
)
135139
}

TablePro/Views/Settings/Plugins/InstalledPluginsView.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ struct InstalledPluginsView: View {
9393
@ViewBuilder
9494
private func pluginRow(_ plugin: PluginEntry) -> some View {
9595
HStack {
96-
Image(systemName: plugin.iconName)
96+
pluginIcon(plugin.iconName)
9797
.frame(width: 20)
9898
.foregroundStyle(plugin.isEnabled ? .primary : .tertiary)
9999

@@ -137,6 +137,16 @@ struct InstalledPluginsView: View {
137137
}
138138
}
139139

140+
@ViewBuilder
141+
private func pluginIcon(_ name: String) -> some View {
142+
if NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil {
143+
Image(systemName: name)
144+
} else {
145+
Image(name)
146+
.renderingMode(.template)
147+
}
148+
}
149+
140150
// MARK: - Detail Section
141151

142152
private var selectedPlugin: PluginEntry? {

TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ struct RegistryPluginDetailView: View {
99
let plugin: RegistryPlugin
1010
let isInstalled: Bool
1111
let installProgress: InstallProgress?
12+
let downloadCount: Int?
1213
let onInstall: () -> Void
1314

1415
var body: some View {
@@ -23,6 +24,13 @@ struct RegistryPluginDetailView: View {
2324
if let minVersion = plugin.minAppVersion {
2425
detailItem(label: "Requires", value: "v\(minVersion)+")
2526
}
27+
28+
if let downloadCount {
29+
detailItem(
30+
label: String(localized: "Downloads"),
31+
value: formattedDownloadCount(downloadCount)
32+
)
33+
}
2634
}
2735

2836
HStack(spacing: 16) {
@@ -63,6 +71,16 @@ struct RegistryPluginDetailView: View {
6371
.padding(.vertical, 8)
6472
}
6573

74+
private static let decimalFormatter: NumberFormatter = {
75+
let formatter = NumberFormatter()
76+
formatter.numberStyle = .decimal
77+
return formatter
78+
}()
79+
80+
private func formattedDownloadCount(_ count: Int) -> String {
81+
Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)"
82+
}
83+
6684
@ViewBuilder
6785
private func detailItem(label: String, value: String) -> some View {
6886
VStack(alignment: .leading, spacing: 2) {

TablePro/Views/Settings/Plugins/RegistryPluginRow.swift

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ struct RegistryPluginRow: View {
99
let plugin: RegistryPlugin
1010
let isInstalled: Bool
1111
let installProgress: InstallProgress?
12+
let downloadCount: Int?
1213
let onInstall: () -> Void
1314
let onToggleDetail: () -> Void
1415

1516
var body: some View {
1617
HStack(spacing: 10) {
17-
Image(systemName: plugin.iconName ?? "puzzlepiece")
18+
pluginIcon(plugin.iconName ?? "puzzlepiece")
1819
.frame(width: 24, height: 24)
1920
.foregroundStyle(.secondary)
2021

@@ -42,6 +43,16 @@ struct RegistryPluginRow: View {
4243
Text(plugin.author.name)
4344
.font(.caption)
4445
.foregroundStyle(.secondary)
46+
47+
if let downloadCount {
48+
Text("\u{2022}")
49+
.font(.caption2)
50+
.foregroundStyle(.quaternary)
51+
52+
Text(formattedCount(downloadCount))
53+
.font(.caption)
54+
.foregroundStyle(.secondary)
55+
}
4556
}
4657
}
4758

@@ -56,6 +67,29 @@ struct RegistryPluginRow: View {
5667
}
5768
}
5869

70+
@ViewBuilder
71+
private func pluginIcon(_ name: String) -> some View {
72+
if NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil {
73+
Image(systemName: name)
74+
} else {
75+
Image(name)
76+
.renderingMode(.template)
77+
}
78+
}
79+
80+
private static let decimalFormatter: NumberFormatter = {
81+
let formatter = NumberFormatter()
82+
formatter.numberStyle = .decimal
83+
return formatter
84+
}()
85+
86+
private func formattedCount(_ count: Int) -> String {
87+
let formatted = Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)"
88+
return count == 1
89+
? String(localized: "\(formatted) download")
90+
: String(localized: "\(formatted) downloads")
91+
}
92+
5993
@ViewBuilder
6094
private var actionButton: some View {
6195
if isInstalled {

0 commit comments

Comments
 (0)