openclacky 1.3.10 → 1.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/lib/clacky/brand_config.rb +32 -6
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +20 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +35 -6
- data/lib/clacky/extension/packager.rb +2 -3
- data/lib/clacky/server/http_server.rb +59 -41
- data/lib/clacky/telemetry.rb +9 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +80 -9
- data/lib/clacky/web/features/extensions/store.js +14 -7
- data/lib/clacky/web/features/extensions/view.js +35 -6
- data/lib/clacky/web/i18n.js +14 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eccc2d78824c13072991199a00edd5b848216334422ce37876cd639854abb4ab
|
|
4
|
+
data.tar.gz: c29754fc48b5b9638168fa167c5335c32ccf5c64a3db94d3e64ac2638581857e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d2e6ef2dcf07a50779241a3ed5b384c2fbf34e4265208874567b12d1280e29f2fe79124286ab7b73251bfc836de01ebf9d8ad03f91ae12ad55ac4575128bfd3
|
|
7
|
+
data.tar.gz: 77088322e7cecc8c0cc0f7138786263c3de14dff2d1b33e99cee763437918e226bd34f0ef318a12d4f7292bcad5a935a8ca2ca16c9a15d8d2bd7262eebee9ccd
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
|
7
|
+
## [1.3.11] - 2026-07-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Delete button for unpublished local extensions in ext-studio
|
|
11
|
+
- Extension install count shown in marketplace
|
|
12
|
+
- Update badge and installed version shown in marketplace list
|
|
13
|
+
|
|
14
|
+
### Improved
|
|
15
|
+
- Extension version history styled with markdown rendering
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Preserve user data on extension update (overwrite instead of replace)
|
|
19
|
+
- Extension install timeout increased to 300s (was too short for large extensions)
|
|
20
|
+
- Installed tab search and loading state in extension store
|
|
21
|
+
- Prevent license loss from non-atomic brand.yml writes
|
|
22
|
+
- Extension detail page content no longer cut off or unscrollable
|
|
23
|
+
- Extension author label position corrected to end of title row
|
|
24
|
+
|
|
7
25
|
## [1.3.10] - 2026-07-09
|
|
8
26
|
|
|
9
27
|
### Added
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -92,17 +92,25 @@ module Clacky
|
|
|
92
92
|
instance = new(data)
|
|
93
93
|
instance.ensure_device_id!
|
|
94
94
|
instance
|
|
95
|
-
rescue StandardError
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
# A read/parse failure here is almost always a transient corruption (e.g. a
|
|
97
|
+
# concurrent write caught mid-truncate). Do NOT touch brand.yml — overwriting
|
|
98
|
+
# it with an empty config would permanently wipe the license, and even moving
|
|
99
|
+
# it aside could lose a file that self-heals a few milliseconds later. Leave
|
|
100
|
+
# the on-disk file untouched and return an in-memory-only config (device_id
|
|
101
|
+
# generated but never saved); the next load usually succeeds.
|
|
102
|
+
Clacky::Logger.error("[Brand] load failed, using in-memory fallback WITHOUT touching brand.yml: #{e.class}: #{e.message}")
|
|
96
103
|
instance = new({})
|
|
97
|
-
instance.ensure_device_id!
|
|
104
|
+
instance.ensure_device_id!(persist: false)
|
|
98
105
|
instance
|
|
99
106
|
end
|
|
100
107
|
|
|
101
|
-
def ensure_device_id!
|
|
108
|
+
def ensure_device_id!(persist: true)
|
|
102
109
|
return if @device_id && !@device_id.strip.empty?
|
|
103
110
|
|
|
111
|
+
Clacky::Logger.warn("[Brand] regenerating device_id (previous was blank; brand.yml may have been reset or corrupted)")
|
|
104
112
|
@device_id = generate_device_id
|
|
105
|
-
save
|
|
113
|
+
save if persist
|
|
106
114
|
end
|
|
107
115
|
|
|
108
116
|
# Returns true when this installation has a product name configured.
|
|
@@ -159,10 +167,28 @@ module Clacky
|
|
|
159
167
|
end
|
|
160
168
|
|
|
161
169
|
# Save current state to ~/.clacky/brand.yml
|
|
170
|
+
#
|
|
171
|
+
# Writes atomically: content goes to a per-process temp file that is fsynced
|
|
172
|
+
# and then renamed over the target. rename is atomic within a filesystem, so
|
|
173
|
+
# concurrent processes (server + CLI, or multiple servers) never observe a
|
|
174
|
+
# truncated or half-written file — which previously caused load to fall back
|
|
175
|
+
# to an empty config and wipe the license.
|
|
162
176
|
def save
|
|
163
177
|
FileUtils.mkdir_p(CONFIG_DIR)
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
Clacky::Logger.debug("[Brand] save: product_name=#{@product_name.inspect} license_key?=#{!@license_key.nil? && !@license_key.to_s.empty?} device_id?=#{!@device_id.nil? && !@device_id.to_s.empty?}")
|
|
179
|
+
|
|
180
|
+
tmp_path = "#{BRAND_FILE}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp"
|
|
181
|
+
begin
|
|
182
|
+
File.open(tmp_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
|
|
183
|
+
f.write(to_yaml)
|
|
184
|
+
f.flush
|
|
185
|
+
f.fsync
|
|
186
|
+
end
|
|
187
|
+
File.rename(tmp_path, BRAND_FILE)
|
|
188
|
+
rescue StandardError
|
|
189
|
+
File.delete(tmp_path) if File.exist?(tmp_path)
|
|
190
|
+
raise
|
|
191
|
+
end
|
|
166
192
|
end
|
|
167
193
|
|
|
168
194
|
# Remove the local license binding and wipe all brand-related fields from disk.
|
|
@@ -120,6 +120,26 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
120
120
|
json(extensions: exts)
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
# DELETE /api/ext/ext-studio/local
|
|
124
|
+
# body: { ext_id }
|
|
125
|
+
# Permanently removes a local extension directory. Only allowed for
|
|
126
|
+
# unpublished extensions (caller must check; server enforces dir existence).
|
|
127
|
+
delete "/local" do
|
|
128
|
+
ext_id = require_ext_id!
|
|
129
|
+
|
|
130
|
+
result = Clacky::ExtensionLoader.load_all(force: false)
|
|
131
|
+
container = Array(result.containers).find { |id, _| id == ext_id }&.last
|
|
132
|
+
error!("extension not found: #{ext_id}", status: 404) unless container
|
|
133
|
+
error!("not a local extension", status: 422) unless container[:layer] == :local
|
|
134
|
+
|
|
135
|
+
dir = container[:dir]
|
|
136
|
+
error!("extension directory not found", status: 404) unless Dir.exist?(dir)
|
|
137
|
+
|
|
138
|
+
FileUtils.rm_rf(dir)
|
|
139
|
+
Clacky::ExtensionLoader.load_all(force: true)
|
|
140
|
+
json(ok: true, ext_id: ext_id)
|
|
141
|
+
end
|
|
142
|
+
|
|
123
143
|
# POST /api/ext/ext-studio/unpublish
|
|
124
144
|
# body: { ext_id }
|
|
125
145
|
post "/unpublish" do
|
|
@@ -100,12 +100,14 @@
|
|
|
100
100
|
"extlist.verify.ok": "Checks passed",
|
|
101
101
|
"extlist.verify.errors": "{{n}} error(s)",
|
|
102
102
|
"extlist.verify.warnings": "{{n}} warning(s)",
|
|
103
|
-
"extlist.btn.publish": "Publish",
|
|
103
|
+
"extlist.btn.publish": "Publish to Marketplace",
|
|
104
104
|
"extlist.btn.update": "Update",
|
|
105
|
-
"extlist.btn.pack": "Pack",
|
|
105
|
+
"extlist.btn.pack": "Pack (.zip)",
|
|
106
106
|
"extlist.btn.packing": "Packing…",
|
|
107
107
|
"extlist.btn.unpublish": "Unpublish",
|
|
108
108
|
"extlist.btn.iterate": "Iterate",
|
|
109
|
+
"extlist.btn.delete": "Delete",
|
|
110
|
+
"extlist.delete.confirm": "Delete local extension \"{{id}}\"? This cannot be undone.",
|
|
109
111
|
"extlist.iterate.seed": "Iterate on extension {{id}}",
|
|
110
112
|
"extlist.changelog.prompt": "Changelog (optional):",
|
|
111
113
|
"extlist.overwrite.confirm": "\"{{id}}\" is already published. Publish a new version?",
|
|
@@ -235,12 +237,14 @@
|
|
|
235
237
|
"extlist.verify.ok": "检查通过",
|
|
236
238
|
"extlist.verify.errors": "{{n}} 个错误",
|
|
237
239
|
"extlist.verify.warnings": "{{n}} 个警告",
|
|
238
|
-
"extlist.btn.publish": "
|
|
239
|
-
"extlist.btn.update": "
|
|
240
|
-
"extlist.btn.pack": "打包",
|
|
240
|
+
"extlist.btn.publish": "发布到市场",
|
|
241
|
+
"extlist.btn.update": "更新到市场",
|
|
242
|
+
"extlist.btn.pack": "打包(.zip)",
|
|
241
243
|
"extlist.btn.packing": "打包中…",
|
|
242
244
|
"extlist.btn.unpublish": "下架",
|
|
243
245
|
"extlist.btn.iterate": "迭代",
|
|
246
|
+
"extlist.btn.delete": "删除",
|
|
247
|
+
"extlist.delete.confirm": "确定要删除本地扩展「{{id}}」吗?此操作不可恢复。",
|
|
244
248
|
"extlist.iterate.seed": "迭代扩展 {{id}}",
|
|
245
249
|
"extlist.changelog.prompt": "更新说明(可选):",
|
|
246
250
|
"extlist.overwrite.confirm": "「{{id}}」已经发布过了。要发布新版本吗?",
|
|
@@ -980,6 +984,12 @@
|
|
|
980
984
|
packBtn.addEventListener("click", () => doPack(ext, packBtn));
|
|
981
985
|
actions.appendChild(packBtn);
|
|
982
986
|
card.appendChild(actions);
|
|
987
|
+
|
|
988
|
+
if (!published) {
|
|
989
|
+
const delBtn = el("button", { class: "studio-btn studio-btn-danger", text: t("extlist.btn.delete") });
|
|
990
|
+
delBtn.addEventListener("click", () => doDelete(ext, delBtn));
|
|
991
|
+
actions.appendChild(delBtn);
|
|
992
|
+
}
|
|
983
993
|
return card;
|
|
984
994
|
}
|
|
985
995
|
|
|
@@ -995,6 +1005,25 @@
|
|
|
995
1005
|
}
|
|
996
1006
|
}
|
|
997
1007
|
|
|
1008
|
+
async function doDelete(ext, btn) {
|
|
1009
|
+
if (!confirm(t("extlist.delete.confirm", { id: ext.id }))) return;
|
|
1010
|
+
btn.disabled = true;
|
|
1011
|
+
try {
|
|
1012
|
+
const res = await fetch("/api/ext/ext-studio/local", {
|
|
1013
|
+
method: "DELETE",
|
|
1014
|
+
headers: { "Content-Type": "application/json" },
|
|
1015
|
+
body: JSON.stringify({ ext_id: ext.id }),
|
|
1016
|
+
});
|
|
1017
|
+
let data = {};
|
|
1018
|
+
try { data = await res.json(); } catch (_e) {}
|
|
1019
|
+
if (!res.ok) throw new Error(data?.error || `Error ${res.status}`);
|
|
1020
|
+
await reload(); rebuild();
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
alert(t("err.generic", { msg: e.message }));
|
|
1023
|
+
btn.disabled = false;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
998
1027
|
async function doPublish(ext, prevVersion) {
|
|
999
1028
|
const ok = await runPublishFlow(ext, prevVersion);
|
|
1000
1029
|
if (ok) { await reload(); rebuild(); }
|
|
@@ -1322,7 +1351,7 @@
|
|
|
1322
1351
|
.studio-btn-primary { background: var(--color-button-primary); color: var(--color-button-primary-text); border-color: transparent; }
|
|
1323
1352
|
.studio-btn-primary:hover { background: var(--color-button-primary-hover); color: var(--color-button-primary-text); }
|
|
1324
1353
|
.studio-btn-primary:disabled { opacity: 0.6; cursor: default; }
|
|
1325
|
-
.studio-btn-danger { color: var(--color-error); border-color: var(--color-error-border);
|
|
1354
|
+
.studio-btn-danger { color: var(--color-error); border-color: var(--color-error-border); }
|
|
1326
1355
|
.studio-btn-danger:hover { background: var(--color-error-bg); color: var(--color-error); }
|
|
1327
1356
|
.studio-btn-ghost { color: var(--color-text-secondary); font-weight: 500; }
|
|
1328
1357
|
.studio-btn-ghost:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
@@ -66,9 +66,8 @@ module Clacky
|
|
|
66
66
|
raise Error, "extension #{ext_id.inspect} already installed at #{target} (pass force: true to overwrite)"
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
FileUtils.mkdir_p(
|
|
70
|
-
FileUtils.
|
|
71
|
-
FileUtils.cp_r(container_src, target)
|
|
69
|
+
FileUtils.mkdir_p(target)
|
|
70
|
+
FileUtils.cp_r(Dir.glob("#{container_src}/*"), target)
|
|
72
71
|
|
|
73
72
|
Clacky::ExtensionLoader.invalidate_cache!
|
|
74
73
|
units = verify_installed(ext_id, installed_dir)
|
|
@@ -477,8 +477,10 @@ module Clacky
|
|
|
477
477
|
elsif path.start_with?("/api/backup/download") || path == "/api/backup/run" || path == "/api/backup/restore"
|
|
478
478
|
# Building/extracting a tar.gz of ~/.clacky can take a while.
|
|
479
479
|
120
|
|
480
|
+
elsif path == "/api/store/extension/install"
|
|
481
|
+
300
|
|
480
482
|
else
|
|
481
|
-
|
|
483
|
+
30
|
|
482
484
|
end
|
|
483
485
|
Timeout.timeout(timeout_sec) do
|
|
484
486
|
_dispatch_rest(req, res)
|
|
@@ -2334,10 +2336,14 @@ module Clacky
|
|
|
2334
2336
|
result = brand.search_extensions!(query: req.query["q"], sort: req.query["sort"])
|
|
2335
2337
|
|
|
2336
2338
|
if result[:success]
|
|
2337
|
-
installed =
|
|
2339
|
+
installed = installed_extension_containers
|
|
2338
2340
|
extensions = Array(result[:extensions]).map do |ext|
|
|
2339
2341
|
slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
|
|
2340
|
-
|
|
2342
|
+
container = installed[slug]
|
|
2343
|
+
ext.merge(
|
|
2344
|
+
"installed" => !container.nil?,
|
|
2345
|
+
"installed_version" => container&.dig(:version)
|
|
2346
|
+
)
|
|
2341
2347
|
end
|
|
2342
2348
|
json_response(res, 200, { ok: true, extensions: extensions })
|
|
2343
2349
|
else
|
|
@@ -2368,24 +2374,26 @@ module Clacky
|
|
|
2368
2374
|
extensions = local_entries.map do |ext_id, container|
|
|
2369
2375
|
market = market_by_slug[ext_id]
|
|
2370
2376
|
{
|
|
2371
|
-
"id"
|
|
2372
|
-
"name"
|
|
2373
|
-
"name_zh"
|
|
2374
|
-
"name_en"
|
|
2375
|
-
"slug"
|
|
2376
|
-
"version"
|
|
2377
|
-
"
|
|
2378
|
-
"
|
|
2379
|
-
"
|
|
2380
|
-
"
|
|
2381
|
-
"
|
|
2382
|
-
"
|
|
2383
|
-
"
|
|
2384
|
-
"
|
|
2385
|
-
"
|
|
2386
|
-
"
|
|
2387
|
-
"
|
|
2388
|
-
"
|
|
2377
|
+
"id" => ext_id,
|
|
2378
|
+
"name" => market ? (market["name"] || ext_id) : ext_id,
|
|
2379
|
+
"name_zh" => market&.dig("name_zh"),
|
|
2380
|
+
"name_en" => market&.dig("name_en"),
|
|
2381
|
+
"slug" => ext_id,
|
|
2382
|
+
"version" => market ? (market["version"] || container[:version]) : container[:version],
|
|
2383
|
+
"installed_version" => container[:version],
|
|
2384
|
+
"description" => market&.dig("description"),
|
|
2385
|
+
"author" => market&.dig("author"),
|
|
2386
|
+
"icon_url" => market&.dig("icon_url"),
|
|
2387
|
+
"units" => market&.dig("units"),
|
|
2388
|
+
"homepage" => market ? (market["homepage"] || "") : "",
|
|
2389
|
+
"origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
|
|
2390
|
+
"hub_active" => market&.dig("hub_active"),
|
|
2391
|
+
"download_count" => market&.dig("download_count").to_i,
|
|
2392
|
+
"unlisted" => market.nil?,
|
|
2393
|
+
"layer" => container[:layer].to_s,
|
|
2394
|
+
"installed" => true,
|
|
2395
|
+
"removable" => true,
|
|
2396
|
+
"disabled" => disabled.include?(ext_id),
|
|
2389
2397
|
}
|
|
2390
2398
|
end
|
|
2391
2399
|
|
|
@@ -2418,6 +2426,13 @@ module Clacky
|
|
|
2418
2426
|
Set.new
|
|
2419
2427
|
end
|
|
2420
2428
|
|
|
2429
|
+
def installed_extension_containers
|
|
2430
|
+
result = Clacky::ExtensionLoader.load_all
|
|
2431
|
+
Array(result&.containers).filter_map { |id, c| [id, c] if c[:layer] == :installed }.to_h
|
|
2432
|
+
rescue StandardError
|
|
2433
|
+
{}
|
|
2434
|
+
end
|
|
2435
|
+
|
|
2421
2436
|
# GET /api/store/extension?id=<slug-or-id>
|
|
2422
2437
|
#
|
|
2423
2438
|
# Public detail for a single marketplace extension (contributes + version
|
|
@@ -2437,9 +2452,10 @@ module Clacky
|
|
|
2437
2452
|
slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
|
|
2438
2453
|
container = extension_container(slug)
|
|
2439
2454
|
ext = ext.merge(
|
|
2440
|
-
"installed"
|
|
2441
|
-
"
|
|
2442
|
-
"
|
|
2455
|
+
"installed" => !container.nil?,
|
|
2456
|
+
"installed_version" => container&.dig(:version),
|
|
2457
|
+
"removable" => container && container[:layer] == :installed,
|
|
2458
|
+
"disabled" => container ? container[:disabled] == true : false
|
|
2443
2459
|
)
|
|
2444
2460
|
json_response(res, 200, { ok: true, extension: ext })
|
|
2445
2461
|
else
|
|
@@ -2447,23 +2463,24 @@ module Clacky
|
|
|
2447
2463
|
if container && container[:layer] == :installed
|
|
2448
2464
|
market = fetch_batch_market_data([id])[id]
|
|
2449
2465
|
ext = {
|
|
2450
|
-
"id"
|
|
2451
|
-
"name"
|
|
2452
|
-
"name_zh"
|
|
2453
|
-
"name_en"
|
|
2454
|
-
"slug"
|
|
2455
|
-
"version"
|
|
2456
|
-
"
|
|
2457
|
-
"
|
|
2458
|
-
"
|
|
2459
|
-
"
|
|
2460
|
-
"
|
|
2461
|
-
"
|
|
2462
|
-
"
|
|
2463
|
-
"
|
|
2464
|
-
"
|
|
2465
|
-
"
|
|
2466
|
-
"
|
|
2466
|
+
"id" => id,
|
|
2467
|
+
"name" => market ? (market["name"] || id) : id,
|
|
2468
|
+
"name_zh" => market&.dig("name_zh"),
|
|
2469
|
+
"name_en" => market&.dig("name_en"),
|
|
2470
|
+
"slug" => id,
|
|
2471
|
+
"version" => market ? (market["version"] || container[:version]) : container[:version],
|
|
2472
|
+
"installed_version" => container[:version],
|
|
2473
|
+
"description" => market&.dig("description"),
|
|
2474
|
+
"author" => market&.dig("author"),
|
|
2475
|
+
"icon_url" => market&.dig("icon_url"),
|
|
2476
|
+
"units" => market&.dig("units"),
|
|
2477
|
+
"homepage" => market ? (market["homepage"] || "") : "",
|
|
2478
|
+
"origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
|
|
2479
|
+
"hub_active" => market&.dig("hub_active"),
|
|
2480
|
+
"unlisted" => market.nil?,
|
|
2481
|
+
"installed" => true,
|
|
2482
|
+
"removable" => true,
|
|
2483
|
+
"disabled" => container[:disabled] == true,
|
|
2467
2484
|
}
|
|
2468
2485
|
json_response(res, 200, { ok: true, extension: ext })
|
|
2469
2486
|
else
|
|
@@ -2519,6 +2536,7 @@ module Clacky
|
|
|
2519
2536
|
|
|
2520
2537
|
Clacky::ExtensionPackager.install(download_url, force: true)
|
|
2521
2538
|
Clacky::ExtensionLoader.invalidate_cache!
|
|
2539
|
+
Clacky::Telemetry.extension_install!(name) unless name.empty?
|
|
2522
2540
|
json_response(res, 200, { ok: true, name: name })
|
|
2523
2541
|
rescue Clacky::ExtensionPackager::Error => e
|
|
2524
2542
|
json_response(res, 422, { ok: false, error: e.message })
|
data/lib/clacky/telemetry.rb
CHANGED
|
@@ -91,6 +91,15 @@ module Clacky
|
|
|
91
91
|
fire_and_forget("/api/v1/telemetry/share", payload.compact)
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
# Called after a marketplace extension is successfully installed.
|
|
95
|
+
# Increments the extension's download_count on the platform.
|
|
96
|
+
def extension_install!(slug)
|
|
97
|
+
return unless enabled?
|
|
98
|
+
return if slug.to_s.strip.empty?
|
|
99
|
+
|
|
100
|
+
fire_and_forget("/api/v1/telemetry/extension_install", { slug: slug })
|
|
101
|
+
end
|
|
102
|
+
|
|
94
103
|
# ── private helpers ────────────────────────────────────────────────
|
|
95
104
|
|
|
96
105
|
private def enabled?
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -7072,6 +7072,12 @@ body {
|
|
|
7072
7072
|
}
|
|
7073
7073
|
|
|
7074
7074
|
/* ── Extension Market ─────────────────────────────────────────────────── */
|
|
7075
|
+
#extensions-panel {
|
|
7076
|
+
flex: 1;
|
|
7077
|
+
display: flex;
|
|
7078
|
+
flex-direction: column;
|
|
7079
|
+
overflow: hidden;
|
|
7080
|
+
}
|
|
7075
7081
|
#extensions-body {
|
|
7076
7082
|
flex: 1;
|
|
7077
7083
|
overflow-y: auto;
|
|
@@ -7183,7 +7189,7 @@ body {
|
|
|
7183
7189
|
.extension-title {
|
|
7184
7190
|
display: flex;
|
|
7185
7191
|
align-items: center;
|
|
7186
|
-
gap: 0.
|
|
7192
|
+
gap: 0.5rem;
|
|
7187
7193
|
margin-bottom: 0.25rem;
|
|
7188
7194
|
flex-wrap: wrap;
|
|
7189
7195
|
}
|
|
@@ -7204,11 +7210,18 @@ body {
|
|
|
7204
7210
|
color: var(--color-text-muted);
|
|
7205
7211
|
}
|
|
7206
7212
|
.extension-installed {
|
|
7207
|
-
font-size: 0.
|
|
7213
|
+
font-size: 0.625rem;
|
|
7208
7214
|
color: var(--color-success, #16a34a);
|
|
7209
7215
|
background: var(--color-success-bg, rgba(22,163,74,0.12));
|
|
7210
7216
|
border-radius: 4px;
|
|
7211
|
-
padding: 0.
|
|
7217
|
+
padding: 0.2rem 0.375rem;
|
|
7218
|
+
}
|
|
7219
|
+
.extension-updatable {
|
|
7220
|
+
font-size: 0.625rem;
|
|
7221
|
+
color: var(--color-button-primary);
|
|
7222
|
+
background: color-mix(in srgb, var(--color-button-primary) 12%, transparent);
|
|
7223
|
+
border-radius: 4px;
|
|
7224
|
+
padding: 0.2rem 0.375rem;
|
|
7212
7225
|
}
|
|
7213
7226
|
.extension-unlisted {
|
|
7214
7227
|
font-size: 0.6875rem;
|
|
@@ -7236,6 +7249,11 @@ body {
|
|
|
7236
7249
|
color: var(--color-text-muted);
|
|
7237
7250
|
font-weight: 400;
|
|
7238
7251
|
}
|
|
7252
|
+
.extension-installs {
|
|
7253
|
+
font-size: 0.75rem;
|
|
7254
|
+
color: var(--color-text-muted);
|
|
7255
|
+
font-weight: 400;
|
|
7256
|
+
}
|
|
7239
7257
|
.extension-meta {
|
|
7240
7258
|
display: flex;
|
|
7241
7259
|
align-items: center;
|
|
@@ -7338,26 +7356,67 @@ body {
|
|
|
7338
7356
|
margin-top: 0.125rem;
|
|
7339
7357
|
}
|
|
7340
7358
|
.extension-version-item {
|
|
7341
|
-
padding: 0.
|
|
7359
|
+
padding: 0.75rem 1rem;
|
|
7342
7360
|
border-bottom: 1px solid var(--color-border-primary);
|
|
7343
7361
|
}
|
|
7344
7362
|
.extension-version-item:last-child { border-bottom: none; }
|
|
7345
7363
|
.extension-version-row {
|
|
7346
7364
|
display: flex;
|
|
7347
|
-
align-items:
|
|
7365
|
+
align-items: baseline;
|
|
7348
7366
|
gap: 0.5rem;
|
|
7349
7367
|
}
|
|
7368
|
+
.extension-version-item:has(.extension-version-notes) .extension-version-row {
|
|
7369
|
+
padding-bottom: 0.5rem;
|
|
7370
|
+
margin-bottom: 0.5rem;
|
|
7371
|
+
border-bottom: 1px solid var(--color-border-primary);
|
|
7372
|
+
}
|
|
7373
|
+
.extension-version-row .extension-version {
|
|
7374
|
+
font-size: 0.9375rem;
|
|
7375
|
+
font-weight: 700;
|
|
7376
|
+
color: var(--color-text-primary);
|
|
7377
|
+
background: none;
|
|
7378
|
+
padding: 0;
|
|
7379
|
+
border-radius: 0;
|
|
7380
|
+
}
|
|
7350
7381
|
.extension-version-date {
|
|
7351
7382
|
font-size: 0.6875rem;
|
|
7352
7383
|
color: var(--color-text-muted);
|
|
7353
7384
|
}
|
|
7354
7385
|
.extension-version-notes {
|
|
7355
|
-
font-size: 0.
|
|
7386
|
+
font-size: 0.8125rem;
|
|
7356
7387
|
color: var(--color-text-secondary);
|
|
7357
|
-
line-height: 1.
|
|
7358
|
-
margin-top: 0.
|
|
7359
|
-
|
|
7388
|
+
line-height: 1.6;
|
|
7389
|
+
margin-top: 0.5rem;
|
|
7390
|
+
}
|
|
7391
|
+
.extension-version-notes h1,
|
|
7392
|
+
.extension-version-notes h2,
|
|
7393
|
+
.extension-version-notes h3 {
|
|
7394
|
+
font-size: 0.8125rem;
|
|
7395
|
+
font-weight: 600;
|
|
7396
|
+
color: var(--color-text-primary);
|
|
7397
|
+
margin: 0.75rem 0 0.25rem;
|
|
7398
|
+
}
|
|
7399
|
+
.extension-version-notes h1 {
|
|
7400
|
+
font-size: 0.9375rem;
|
|
7360
7401
|
}
|
|
7402
|
+
.extension-version-notes h1:first-child,
|
|
7403
|
+
.extension-version-notes h2:first-child,
|
|
7404
|
+
.extension-version-notes h3:first-child {
|
|
7405
|
+
margin-top: 0;
|
|
7406
|
+
}
|
|
7407
|
+
.extension-version-notes ul,
|
|
7408
|
+
.extension-version-notes ol {
|
|
7409
|
+
margin: 0.25rem 0;
|
|
7410
|
+
padding-left: 1.25rem;
|
|
7411
|
+
}
|
|
7412
|
+
.extension-version-notes li {
|
|
7413
|
+
margin: 0.1rem 0;
|
|
7414
|
+
}
|
|
7415
|
+
.extension-version-notes p {
|
|
7416
|
+
margin: 0.25rem 0;
|
|
7417
|
+
}
|
|
7418
|
+
.extension-version-notes p:first-child { margin-top: 0; }
|
|
7419
|
+
.extension-version-notes p:last-child { margin-bottom: 0; }
|
|
7361
7420
|
.extension-detail-loading { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
7362
7421
|
|
|
7363
7422
|
.extension-detail-actions {
|
|
@@ -7399,6 +7458,18 @@ body {
|
|
|
7399
7458
|
opacity: 0.5;
|
|
7400
7459
|
cursor: not-allowed;
|
|
7401
7460
|
}
|
|
7461
|
+
.extension-action-update {
|
|
7462
|
+
background: var(--color-button-primary);
|
|
7463
|
+
color: var(--color-button-primary-text);
|
|
7464
|
+
border-color: transparent;
|
|
7465
|
+
}
|
|
7466
|
+
.extension-action-update:hover {
|
|
7467
|
+
background: var(--color-button-primary-hover);
|
|
7468
|
+
}
|
|
7469
|
+
.extension-action-update:disabled {
|
|
7470
|
+
opacity: 0.5;
|
|
7471
|
+
cursor: not-allowed;
|
|
7472
|
+
}
|
|
7402
7473
|
.extension-action-disable,
|
|
7403
7474
|
.extension-action-enable {
|
|
7404
7475
|
color: var(--color-text-tertiary);
|
|
@@ -66,6 +66,7 @@ const ExtensionsStore = (() => {
|
|
|
66
66
|
|
|
67
67
|
/** Fetch the catalog from the server for the current query + sort. */
|
|
68
68
|
async load() {
|
|
69
|
+
if (_filterInstalled) return;
|
|
69
70
|
_loading = true;
|
|
70
71
|
_error = null;
|
|
71
72
|
_emit("extensions:loading");
|
|
@@ -79,12 +80,8 @@ const ExtensionsStore = (() => {
|
|
|
79
80
|
_allExtensions = data.extensions || [];
|
|
80
81
|
_error = data.warning || null;
|
|
81
82
|
_loading = false;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
} else {
|
|
85
|
-
_extensions = _allExtensions;
|
|
86
|
-
_emit("extensions:changed", { extensions: _extensions, warning: _error });
|
|
87
|
-
}
|
|
83
|
+
_extensions = _allExtensions;
|
|
84
|
+
_emit("extensions:changed", { extensions: _extensions, warning: _error });
|
|
88
85
|
} catch (e) {
|
|
89
86
|
console.error("[Extensions] load failed", e);
|
|
90
87
|
_extensions = [];
|
|
@@ -99,6 +96,7 @@ const ExtensionsStore = (() => {
|
|
|
99
96
|
/** Set the search text and reload. */
|
|
100
97
|
setQuery(query) {
|
|
101
98
|
_query = (query || "").trim();
|
|
99
|
+
if (_filterInstalled) return Extensions.setFilterInstalled(true);
|
|
102
100
|
return Extensions.load();
|
|
103
101
|
},
|
|
104
102
|
|
|
@@ -117,7 +115,11 @@ const ExtensionsStore = (() => {
|
|
|
117
115
|
try {
|
|
118
116
|
const res = await fetch("/api/store/extensions/installed");
|
|
119
117
|
const data = await res.json();
|
|
120
|
-
|
|
118
|
+
const all = data.extensions || [];
|
|
119
|
+
_extensions = _query
|
|
120
|
+
? all.filter(e => [e.name, e.name_zh, e.description].some(
|
|
121
|
+
f => f && f.toLowerCase().includes(_query.toLowerCase())))
|
|
122
|
+
: all;
|
|
121
123
|
} catch (e) {
|
|
122
124
|
console.error("[Extensions] load installed failed", e);
|
|
123
125
|
_extensions = [];
|
|
@@ -210,6 +212,11 @@ const ExtensionsStore = (() => {
|
|
|
210
212
|
}
|
|
211
213
|
},
|
|
212
214
|
|
|
215
|
+
/** Update an installed extension to the latest marketplace version. */
|
|
216
|
+
async update(id) {
|
|
217
|
+
return Extensions.install(id);
|
|
218
|
+
},
|
|
219
|
+
|
|
213
220
|
/** Remove an installed extension, then return to the list. */
|
|
214
221
|
async uninstall(id) {
|
|
215
222
|
if (!id) return;
|
|
@@ -87,8 +87,11 @@ const ExtensionsView = (() => {
|
|
|
87
87
|
|
|
88
88
|
const versionHtml = ext.version
|
|
89
89
|
? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
|
|
90
|
+
const canUpdate = ext.installed_version && ext.version && ext.installed_version !== ext.version;
|
|
90
91
|
const installedHtml = ext.installed
|
|
91
92
|
? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
|
|
93
|
+
const updatableHtml = canUpdate
|
|
94
|
+
? `<span class="extension-updatable">${escapeHtml(I18n.t("extensions.updatable"))}</span>` : "";
|
|
92
95
|
const unlistedHtml = (ext.unlisted && ext.installed)
|
|
93
96
|
? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
|
|
94
97
|
const unitsText = _formatUnits(ext.units);
|
|
@@ -98,7 +101,9 @@ const ExtensionsView = (() => {
|
|
|
98
101
|
? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
|
|
99
102
|
: "";
|
|
100
103
|
const authorHtml = ext.author
|
|
101
|
-
? `<span class="extension-author"
|
|
104
|
+
? `<span class="extension-author">${escapeHtml(I18n.t("extensions.by"))}${escapeHtml(ext.author)}</span>` : "";
|
|
105
|
+
const installsHtml = ext.download_count > 0
|
|
106
|
+
? `<span class="extension-installs">${escapeHtml(String(ext.download_count))} ${escapeHtml(I18n.t("extensions.installs"))}</span>` : "";
|
|
102
107
|
|
|
103
108
|
const card = document.createElement("div");
|
|
104
109
|
card.className = "extension-card extension-card-clickable";
|
|
@@ -109,11 +114,13 @@ const ExtensionsView = (() => {
|
|
|
109
114
|
<div class="extension-info">
|
|
110
115
|
<div class="extension-title">
|
|
111
116
|
<span class="extension-name">${escapeHtml(name)}</span>
|
|
112
|
-
${authorHtml}
|
|
113
117
|
${versionHtml}
|
|
114
118
|
${installedHtml}
|
|
119
|
+
${updatableHtml}
|
|
115
120
|
${unlistedHtml}
|
|
116
121
|
${unitsHtml}
|
|
122
|
+
${authorHtml}
|
|
123
|
+
${installsHtml}
|
|
117
124
|
</div>
|
|
118
125
|
${description ? `<div class="extension-desc">${escapeHtml(description)}</div>` : ""}
|
|
119
126
|
${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
|
|
@@ -202,6 +209,16 @@ const ExtensionsView = (() => {
|
|
|
202
209
|
Extensions.install(id);
|
|
203
210
|
});
|
|
204
211
|
}
|
|
212
|
+
|
|
213
|
+
const updateBtn = document.querySelector("[data-ext-update]");
|
|
214
|
+
if (updateBtn) {
|
|
215
|
+
updateBtn.addEventListener("click", () => {
|
|
216
|
+
const id = updateBtn.getAttribute("data-ext-update");
|
|
217
|
+
updateBtn.disabled = true;
|
|
218
|
+
updateBtn.textContent = I18n.t("extensions.action.updating");
|
|
219
|
+
Extensions.update(id);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
function _backToList() {
|
|
@@ -218,10 +235,14 @@ const ExtensionsView = (() => {
|
|
|
218
235
|
: ext.description || "";
|
|
219
236
|
const emoji = ext.emoji || "🧩";
|
|
220
237
|
|
|
238
|
+
const canUpdate = ext.installed && ext.installed_version && ext.version && ext.installed_version !== ext.version;
|
|
221
239
|
const versionHtml = ext.version
|
|
222
240
|
? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
|
|
241
|
+
const installedLabel = canUpdate && ext.installed_version
|
|
242
|
+
? `${I18n.t("extensions.installed")} v${escapeHtml(String(ext.installed_version))}`
|
|
243
|
+
: I18n.t("extensions.installed");
|
|
223
244
|
const installedHtml = ext.installed
|
|
224
|
-
? `<span class="extension-installed">${
|
|
245
|
+
? `<span class="extension-installed">${installedLabel}</span>` : "";
|
|
225
246
|
const unlistedHtml = ext.unlisted
|
|
226
247
|
? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
|
|
227
248
|
const unitsText = _formatUnits(ext.units);
|
|
@@ -231,7 +252,9 @@ const ExtensionsView = (() => {
|
|
|
231
252
|
? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
|
|
232
253
|
: "";
|
|
233
254
|
const authorHtml = ext.author
|
|
234
|
-
? `<span class="extension-author"
|
|
255
|
+
? `<span class="extension-author">${escapeHtml(I18n.t("extensions.by"))}${escapeHtml(ext.author)}</span>` : "";
|
|
256
|
+
const installsHtml = ext.download_count > 0
|
|
257
|
+
? `<span class="extension-installs">${escapeHtml(String(ext.download_count))} ${escapeHtml(I18n.t("extensions.installs"))}</span>` : "";
|
|
235
258
|
|
|
236
259
|
return `
|
|
237
260
|
<div class="extension-detail-hero">
|
|
@@ -239,11 +262,12 @@ const ExtensionsView = (() => {
|
|
|
239
262
|
<div class="extension-detail-heading">
|
|
240
263
|
<div class="extension-title">
|
|
241
264
|
<span class="extension-name extension-name-lg">${escapeHtml(name)}</span>
|
|
242
|
-
${authorHtml}
|
|
243
265
|
${versionHtml}
|
|
244
266
|
${installedHtml}
|
|
245
267
|
${unlistedHtml}
|
|
246
268
|
${unitsHtml}
|
|
269
|
+
${authorHtml}
|
|
270
|
+
${installsHtml}
|
|
247
271
|
</div>
|
|
248
272
|
${description ? `<div class="extension-desc extension-desc-detail">${escapeHtml(description)}</div>` : ""}
|
|
249
273
|
${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
|
|
@@ -275,9 +299,14 @@ const ExtensionsView = (() => {
|
|
|
275
299
|
const removeBtn = ext.removable
|
|
276
300
|
? `<button type="button" class="extension-action extension-action-remove" data-ext-remove="${escapeHtml(slug)}">${escapeHtml(I18n.t("extensions.action.remove"))}</button>`
|
|
277
301
|
: "";
|
|
302
|
+
const canUpdate = ext.installed_version && ext.version && ext.installed_version !== ext.version && ext.download_url;
|
|
303
|
+
const updateBtn = canUpdate
|
|
304
|
+
? `<button type="button" class="extension-action extension-action-update" data-ext-update="${escapeHtml(id)}">${escapeHtml(I18n.t("extensions.action.update"))}</button>`
|
|
305
|
+
: "";
|
|
278
306
|
return `
|
|
279
307
|
<div class="extension-detail-actions">
|
|
280
308
|
${disabledBadge}
|
|
309
|
+
${updateBtn}
|
|
281
310
|
<button type="button" class="extension-action ${toggleCls}" data-ext-toggle="${escapeHtml(slug)}" data-ext-enabled="${ext.disabled ? "1" : "0"}">${escapeHtml(I18n.t(toggleKey))}</button>
|
|
282
311
|
${removeBtn}
|
|
283
312
|
</div>`;
|
|
@@ -338,7 +367,7 @@ const ExtensionsView = (() => {
|
|
|
338
367
|
<span class="extension-version">v${escapeHtml(String(v.version || ""))}</span>
|
|
339
368
|
${date ? `<span class="extension-version-date">${escapeHtml(date)}</span>` : ""}
|
|
340
369
|
</div>
|
|
341
|
-
${v.release_notes ? `<div class="extension-version-notes">${escapeHtml(String(v.release_notes))}</div>` : ""}
|
|
370
|
+
${v.release_notes ? `<div class="extension-version-notes">${typeof marked !== "undefined" ? marked.parse(String(v.release_notes).replace(/^#{1,3}[^\n]*\n?/, ""), { breaks: true, gfm: true }) : escapeHtml(String(v.release_notes))}</div>` : ""}
|
|
342
371
|
</li>`;
|
|
343
372
|
}).join("");
|
|
344
373
|
return `
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -512,15 +512,17 @@ const I18n = (() => {
|
|
|
512
512
|
"extensions.searchPlaceholder": "Search extensions…",
|
|
513
513
|
"extensions.sort.newest": "Newest",
|
|
514
514
|
"extensions.sort.updated": "Recently updated",
|
|
515
|
-
"extensions.sort.downloads": "Most
|
|
515
|
+
"extensions.sort.downloads": "Most installed",
|
|
516
516
|
"extensions.filter.all": "All",
|
|
517
517
|
"extensions.filter.installed": "Installed",
|
|
518
518
|
"extensions.empty": "No extensions available yet.",
|
|
519
519
|
"extensions.noResults": "No extensions match your search.",
|
|
520
520
|
"extensions.loadFailed": "Could not reach the extension store.",
|
|
521
521
|
"extensions.homepage": "Homepage",
|
|
522
|
-
"extensions.by": "by",
|
|
523
|
-
"extensions.
|
|
522
|
+
"extensions.by": "by ",
|
|
523
|
+
"extensions.installs": "installs",
|
|
524
|
+
"extensions.installed": "Installed",
|
|
525
|
+
"extensions.updatable": "Update available",
|
|
524
526
|
"extensions.unlisted": "Unlisted",
|
|
525
527
|
"extensions.unit.skill": "skill",
|
|
526
528
|
"extensions.unit.skills": "skills",
|
|
@@ -540,6 +542,8 @@ const I18n = (() => {
|
|
|
540
542
|
"extensions.disabled": "Disabled",
|
|
541
543
|
"extensions.action.install": "Install",
|
|
542
544
|
"extensions.action.installing": "Installing…",
|
|
545
|
+
"extensions.action.update": "Update",
|
|
546
|
+
"extensions.action.updating": "Updating…",
|
|
543
547
|
"extensions.action.disable": "Disable",
|
|
544
548
|
"extensions.action.enable": "Enable",
|
|
545
549
|
"extensions.action.remove": "Remove",
|
|
@@ -1475,15 +1479,17 @@ const I18n = (() => {
|
|
|
1475
1479
|
"extensions.searchPlaceholder": "搜索扩展…",
|
|
1476
1480
|
"extensions.sort.newest": "最新",
|
|
1477
1481
|
"extensions.sort.updated": "最近更新",
|
|
1478
|
-
"extensions.sort.downloads": "
|
|
1482
|
+
"extensions.sort.downloads": "安装最多",
|
|
1479
1483
|
"extensions.filter.all": "全部",
|
|
1480
1484
|
"extensions.filter.installed": "已安装",
|
|
1481
1485
|
"extensions.empty": "暂无可用扩展。",
|
|
1482
1486
|
"extensions.noResults": "没有匹配的扩展。",
|
|
1483
1487
|
"extensions.loadFailed": "无法连接扩展市场。",
|
|
1484
1488
|
"extensions.homepage": "主页",
|
|
1485
|
-
"extensions.by": "
|
|
1486
|
-
"extensions.
|
|
1489
|
+
"extensions.by": "作者:",
|
|
1490
|
+
"extensions.installs": "次安装",
|
|
1491
|
+
"extensions.installed": "已安装",
|
|
1492
|
+
"extensions.updatable": "有更新",
|
|
1487
1493
|
"extensions.unlisted": "已下架",
|
|
1488
1494
|
"extensions.unit.skill": "个技能",
|
|
1489
1495
|
"extensions.unit.skills": "个技能",
|
|
@@ -1503,6 +1509,8 @@ const I18n = (() => {
|
|
|
1503
1509
|
"extensions.disabled": "已禁用",
|
|
1504
1510
|
"extensions.action.install": "安装",
|
|
1505
1511
|
"extensions.action.installing": "安装中…",
|
|
1512
|
+
"extensions.action.update": "更新",
|
|
1513
|
+
"extensions.action.updating": "更新中…",
|
|
1506
1514
|
"extensions.action.disable": "禁用",
|
|
1507
1515
|
"extensions.action.enable": "启用",
|
|
1508
1516
|
"extensions.action.remove": "移除",
|