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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de09b43a446d08c592e1cd49deb914a3ff8c70375cf653f130d2aef9a1cd460e
4
- data.tar.gz: 8030ce355abbe973c9b8294995744b5d4a96e3e38bfc494cb081621172619085
3
+ metadata.gz: eccc2d78824c13072991199a00edd5b848216334422ce37876cd639854abb4ab
4
+ data.tar.gz: c29754fc48b5b9638168fa167c5335c32ccf5c64a3db94d3e64ac2638581857e
5
5
  SHA512:
6
- metadata.gz: bd57f31e2d97f1a98b10a775017e2c42ce6354e3fab4a87f9392e884ecca3a2ca3c3ec19d30e8adcb7d8498330446b3d80be787c3594e26b63d07a3e2349f9a7
7
- data.tar.gz: 4b18ae8839c6bd9ab319c7077dca1bc61ea836d38152438215c61a524099c7e17213b0b76f37f8157621a7b168325042fe3137bf7d0b5e257a3609c58ca7c8fc
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
@@ -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
- File.write(BRAND_FILE, to_yaml)
165
- FileUtils.chmod(0o600, BRAND_FILE)
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); padding: 4px 10px; }
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(installed_dir)
70
- FileUtils.rm_rf(target) if Dir.exist?(target)
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
- 10
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 = installed_extension_slugs
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
- ext.merge("installed" => installed.include?(slug))
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" => ext_id,
2372
- "name" => market ? (market["name"] || ext_id) : ext_id,
2373
- "name_zh" => market&.dig("name_zh"),
2374
- "name_en" => market&.dig("name_en"),
2375
- "slug" => ext_id,
2376
- "version" => market ? (market["version"] || container[:version]) : container[:version],
2377
- "description" => market&.dig("description"),
2378
- "author" => market&.dig("author"),
2379
- "icon_url" => market&.dig("icon_url"),
2380
- "units" => market&.dig("units"),
2381
- "homepage" => market ? (market["homepage"] || "") : "",
2382
- "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2383
- "hub_active" => market&.dig("hub_active"),
2384
- "unlisted" => market.nil?,
2385
- "layer" => container[:layer].to_s,
2386
- "installed" => true,
2387
- "removable" => true,
2388
- "disabled" => disabled.include?(ext_id),
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" => !container.nil?,
2441
- "removable" => container && container[:layer] == :installed,
2442
- "disabled" => container ? container[:disabled] == true : false
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" => id,
2451
- "name" => market ? (market["name"] || id) : id,
2452
- "name_zh" => market&.dig("name_zh"),
2453
- "name_en" => market&.dig("name_en"),
2454
- "slug" => id,
2455
- "version" => market ? (market["version"] || container[:version]) : container[:version],
2456
- "description" => market&.dig("description"),
2457
- "author" => market&.dig("author"),
2458
- "icon_url" => market&.dig("icon_url"),
2459
- "units" => market&.dig("units"),
2460
- "homepage" => market ? (market["homepage"] || "") : "",
2461
- "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2462
- "hub_active" => market&.dig("hub_active"),
2463
- "unlisted" => market.nil?,
2464
- "installed" => true,
2465
- "removable" => true,
2466
- "disabled" => container[:disabled] == true,
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 })
@@ -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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.10"
4
+ VERSION = "1.3.11"
5
5
  end
@@ -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.375rem;
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.6875rem;
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.0625rem 0.375rem;
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.625rem 0.875rem;
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: center;
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.75rem;
7386
+ font-size: 0.8125rem;
7356
7387
  color: var(--color-text-secondary);
7357
- line-height: 1.5;
7358
- margin-top: 0.25rem;
7359
- white-space: pre-wrap;
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
- if (_filterInstalled) {
83
- await Extensions.setFilterInstalled(true);
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
- _extensions = data.extensions || [];
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"${escapeHtml(ext.author)}</span>` : "";
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">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
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"${escapeHtml(ext.author)}</span>` : "";
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 `
@@ -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 downloaded",
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.installed": "Installed",
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.installed": "已安装",
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": "移除",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.10
4
+ version: 1.3.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy