openclacky 1.5.2 → 1.5.4

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +50 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +13 -15
  10. data/lib/clacky/agent/session_serializer.rb +6 -0
  11. data/lib/clacky/agent.rb +13 -1
  12. data/lib/clacky/agent_config.rb +42 -11
  13. data/lib/clacky/brand_config.rb +32 -12
  14. data/lib/clacky/client.rb +7 -0
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
  17. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
  19. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  20. data/lib/clacky/extension/api_extension.rb +3 -2
  21. data/lib/clacky/message_format/anthropic.rb +27 -1
  22. data/lib/clacky/message_format/open_ai.rb +92 -4
  23. data/lib/clacky/providers.rb +67 -14
  24. data/lib/clacky/server/http_server.rb +361 -36
  25. data/lib/clacky/server/project_manager.rb +150 -0
  26. data/lib/clacky/server/server_master.rb +7 -0
  27. data/lib/clacky/server/session_registry.rb +14 -1
  28. data/lib/clacky/session_manager.rb +5 -4
  29. data/lib/clacky/tools/file_reader.rb +2 -1
  30. data/lib/clacky/utils/model_pricing.rb +77 -18
  31. data/lib/clacky/utils/parser_manager.rb +115 -7
  32. data/lib/clacky/version.rb +1 -1
  33. data/lib/clacky/web/app.css +1053 -143
  34. data/lib/clacky/web/app.js +19 -5
  35. data/lib/clacky/web/components/code-editor.js +50 -12
  36. data/lib/clacky/web/components/notify.js +139 -22
  37. data/lib/clacky/web/core/aside.js +21 -0
  38. data/lib/clacky/web/features/billing/view.js +2 -1
  39. data/lib/clacky/web/features/brand/store.js +3 -1
  40. data/lib/clacky/web/features/brand/view.js +29 -5
  41. data/lib/clacky/web/features/extensions/store.js +15 -6
  42. data/lib/clacky/web/features/extensions/view.js +8 -4
  43. data/lib/clacky/web/features/new-session/view.js +2 -2
  44. data/lib/clacky/web/features/trash/store.js +23 -9
  45. data/lib/clacky/web/features/trash/view.js +51 -0
  46. data/lib/clacky/web/features/workspace/store.js +9 -0
  47. data/lib/clacky/web/features/workspace/view.js +8 -1
  48. data/lib/clacky/web/i18n.js +99 -4
  49. data/lib/clacky/web/index.html +70 -17
  50. data/lib/clacky/web/projects.js +1143 -0
  51. data/lib/clacky/web/sessions.js +318 -537
  52. data/lib/clacky/web/settings.js +146 -7
  53. data/lib/clacky/web/ws-dispatcher.js +59 -1
  54. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  55. data/scripts/install_system_deps.sh +11 -3
  56. metadata +5 -2
  57. data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
@@ -20,6 +20,7 @@ const Settings = (() => {
20
20
  _loadBrand();
21
21
  _loadBrowserStatus();
22
22
  _initNetworkSettings();
23
+ _initPlatformSourceSettings();
23
24
  if (Clacky.Backup) Clacky.Backup.load();
24
25
  _applyAboutTabVisibility();
25
26
  }
@@ -528,8 +529,14 @@ const Settings = (() => {
528
529
 
529
530
  // Toggle API key visibility
530
531
  document.getElementById("model-modal-toggle-key").addEventListener("click", () => {
531
- const input = document.getElementById("model-modal-apikey");
532
- input.type = input.type === "password" ? "text" : "password";
532
+ const input = document.getElementById("model-modal-apikey");
533
+ const eyeSvg = document.getElementById("model-modal-toggle-key").querySelector("svg");
534
+ const isPassword = input.type === "password";
535
+ input.type = isPassword ? "text" : "password";
536
+ // Switch icon: eye-off ↔ eye
537
+ eyeSvg.innerHTML = isPassword
538
+ ? `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>`
539
+ : `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>`;
533
540
  });
534
541
 
535
542
  // Model dropdown functionality
@@ -1380,6 +1387,137 @@ const Settings = (() => {
1380
1387
 
1381
1388
  // Whether the server was started with --brand-test (relaxed key validation).
1382
1389
  let _brandTestMode = false;
1390
+ let _loadedPlatformSource = "";
1391
+ const OFFICIAL_PLATFORM_SOURCE = "https://www.openclacky.com";
1392
+
1393
+ async function _initPlatformSourceSettings() {
1394
+ const input = document.getElementById("settings-clacky-license-server");
1395
+ const saveBtn = document.getElementById("btn-save-clacky-license-server");
1396
+ const restoreBtn = document.getElementById("btn-restore-clacky-license-server");
1397
+ if (!input || !saveBtn) return;
1398
+
1399
+ try {
1400
+ const res = await fetch("/api/config/settings");
1401
+ const data = await res.json();
1402
+ if (data.ok) {
1403
+ _loadedPlatformSource = data.clacky_license_server || "";
1404
+ input.value = _loadedPlatformSource;
1405
+ }
1406
+ } catch (_) { /* non-critical */ }
1407
+ _syncPlatformSourceButtons();
1408
+
1409
+ if (!saveBtn.dataset.bound) {
1410
+ saveBtn.dataset.bound = "1";
1411
+ saveBtn.addEventListener("click", () => _savePlatformSource());
1412
+ input.addEventListener("keydown", e => {
1413
+ if (e.key === "Enter") _savePlatformSource();
1414
+ });
1415
+ }
1416
+ if (restoreBtn && !restoreBtn.dataset.bound) {
1417
+ restoreBtn.dataset.bound = "1";
1418
+ restoreBtn.addEventListener("click", () => {
1419
+ _savePlatformSource(OFFICIAL_PLATFORM_SOURCE);
1420
+ });
1421
+ }
1422
+ }
1423
+
1424
+ function _platformSourceOrigin(value) {
1425
+ try {
1426
+ return new URL(value).origin.toLowerCase();
1427
+ } catch (_) {
1428
+ return value.trim();
1429
+ }
1430
+ }
1431
+
1432
+ function _syncPlatformSourceButtons({ busy = false } = {}) {
1433
+ const saveBtn = document.getElementById("btn-save-clacky-license-server");
1434
+ const restoreBtn = document.getElementById("btn-restore-clacky-license-server");
1435
+ if (saveBtn) saveBtn.disabled = busy;
1436
+ if (restoreBtn) {
1437
+ const official = _platformSourceOrigin(OFFICIAL_PLATFORM_SOURCE);
1438
+ restoreBtn.disabled = busy ||
1439
+ _platformSourceOrigin(_loadedPlatformSource) === official;
1440
+ }
1441
+ }
1442
+
1443
+ async function _savePlatformSource(sourceOverride = null) {
1444
+ const input = document.getElementById("settings-clacky-license-server");
1445
+ const saveBtn = document.getElementById("btn-save-clacky-license-server");
1446
+ const status = document.getElementById("settings-clacky-license-server-status");
1447
+ const source = typeof sourceOverride === "string"
1448
+ ? sourceOverride
1449
+ : input.value.trim();
1450
+ if (saveBtn.disabled) return;
1451
+
1452
+ status.textContent = "";
1453
+ status.className = "model-test-result";
1454
+ if (!source) {
1455
+ status.textContent = I18n.t("settings.brand.source.required");
1456
+ status.className = "model-test-result result-fail";
1457
+ return;
1458
+ }
1459
+
1460
+ const sourceChanged =
1461
+ _platformSourceOrigin(source) !== _platformSourceOrigin(_loadedPlatformSource);
1462
+ if (sourceChanged) {
1463
+ _syncPlatformSourceButtons({ busy: true });
1464
+ const confirmed = await Modal.confirm(I18n.t("settings.brand.source.confirm"));
1465
+ if (!confirmed) {
1466
+ _syncPlatformSourceButtons();
1467
+ return;
1468
+ }
1469
+ }
1470
+
1471
+ _syncPlatformSourceButtons({ busy: true });
1472
+ saveBtn.textContent = I18n.t("settings.brand.source.saving");
1473
+
1474
+ try {
1475
+ const res = await fetch("/api/config/settings", {
1476
+ method: "PATCH",
1477
+ headers: { "Content-Type": "application/json" },
1478
+ body: JSON.stringify({ clacky_license_server: source })
1479
+ });
1480
+ const data = await res.json();
1481
+ if (!res.ok || !data.ok) {
1482
+ throw new Error(data.error || I18n.t("settings.brand.source.invalid"));
1483
+ }
1484
+
1485
+ _loadedPlatformSource = data.clacky_license_server;
1486
+ input.value = _loadedPlatformSource;
1487
+
1488
+ if (data.restarting) {
1489
+ status.textContent = I18n.t("settings.brand.source.reconnecting");
1490
+ status.className = "model-test-result result-ok";
1491
+ const reconnected = await _waitForPlatformSourceRestart();
1492
+ if (!reconnected) {
1493
+ throw new Error(I18n.t("settings.brand.source.reconnectFailed"));
1494
+ }
1495
+ }
1496
+
1497
+ await Brand.refresh();
1498
+ status.textContent = I18n.t("settings.brand.source.saved");
1499
+ status.className = "model-test-result result-ok";
1500
+ } catch (e) {
1501
+ status.textContent = e.message || I18n.t("settings.brand.source.invalid");
1502
+ status.className = "model-test-result result-fail";
1503
+ } finally {
1504
+ _syncPlatformSourceButtons();
1505
+ saveBtn.textContent = I18n.t("settings.brand.source.save");
1506
+ }
1507
+ }
1508
+
1509
+ async function _waitForPlatformSourceRestart() {
1510
+ // The old Worker can still answer HTTP while the Master is shutting it
1511
+ // down, so an HTTP success does not prove the new source is active. A full
1512
+ // WebSocket disconnect/reconnect cycle identifies the replacement Worker.
1513
+ let disconnected = !WS.ready;
1514
+ for (let attempt = 0; attempt < 40; attempt += 1) {
1515
+ if (!WS.ready) disconnected = true;
1516
+ if (disconnected && WS.ready) return true;
1517
+ await new Promise(resolve => setTimeout(resolve, 500));
1518
+ }
1519
+ return false;
1520
+ }
1383
1521
 
1384
1522
  // Load and render the current brand/license status in Settings.
1385
1523
  async function _loadBrand() {
@@ -1486,11 +1624,12 @@ const Settings = (() => {
1486
1624
  statusCard.style.display = "none";
1487
1625
  activateForm.style.display = "";
1488
1626
 
1489
- // Pre-fill brand name in input placeholder if we know it
1490
- if (data.product_name) {
1491
- const desc = activateForm.querySelector(".brand-activate-desc");
1492
- if (desc) desc.textContent =
1493
- I18n.t("settings.brand.descNamed", { name: data.product_name });
1627
+ // Keep the activation prompt in sync when platform branding changes.
1628
+ const desc = activateForm.querySelector(".brand-activate-desc");
1629
+ if (desc) {
1630
+ desc.textContent = data.product_name
1631
+ ? I18n.t("settings.brand.descNamed", { name: data.product_name })
1632
+ : I18n.t("settings.brand.desc");
1494
1633
  }
1495
1634
 
1496
1635
  // Show "Get a serial number" link only when the brand vendor has
@@ -181,7 +181,9 @@ WS.onEvent(ev => {
181
181
  // ── Session list ───────────────────────────────────────────────────
182
182
  case "session_list": {
183
183
  Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0, ev.latest_cron_updated_at || null);
184
+ Projects.setAll(ev.projects || []);
184
185
  Sessions.renderList();
186
+ Projects.renderSection();
185
187
 
186
188
  // Restore URL hash once on initial connect; ignore subsequent session_list events.
187
189
  // Skip if we are already on a session view (e.g. onboard flow navigated there
@@ -257,6 +259,7 @@ WS.onEvent(ev => {
257
259
  if (!sid) break;
258
260
  Sessions.patch(sid, patch);
259
261
  Sessions.renderList();
262
+ Projects.renderSection();
260
263
  if (sid === Sessions.activeId) {
261
264
  const current = Sessions.find(sid);
262
265
  if (patch.status !== undefined) Sessions.updateStatusBar(patch.status);
@@ -285,6 +288,7 @@ WS.onEvent(ev => {
285
288
  case "session_renamed": {
286
289
  Sessions.patch(ev.session_id, { name: ev.name });
287
290
  Sessions.renderList();
291
+ Projects.renderSection();
288
292
  // Title is now shown only in the sidebar; chat-header element was removed.
289
293
  break;
290
294
  }
@@ -294,6 +298,7 @@ WS.onEvent(ev => {
294
298
  Sessions.remove(ev.session_id);
295
299
  if (ev.session_id === Sessions.activeId) Router.navigate("welcome");
296
300
  Sessions.renderList();
301
+ Projects.renderSection();
297
302
  break;
298
303
 
299
304
  case "session_restored":
@@ -303,6 +308,7 @@ WS.onEvent(ev => {
303
308
  if (ev.session) {
304
309
  Sessions.add(ev.session);
305
310
  Sessions.renderList();
311
+ Projects.renderSection();
306
312
  }
307
313
  break;
308
314
 
@@ -438,10 +444,62 @@ WS.onEvent(ev => {
438
444
  renderErrorEvent(ev);
439
445
  }
440
446
  break;
447
+
448
+ // ── UI control signals ─────────────────────────────────────────────
449
+ case "open_aside":
450
+ // Triggered by the AI (via POST /api/ui/open_aside) after building an
451
+ // extension whose panel lives in the right-side aside column.
452
+ if (window.Clacky && Clacky.Aside) Clacky.Aside.open();
453
+ break;
454
+
455
+ case "show_ext_refresh":
456
+ // Triggered by the AI (via POST /api/ui/show_ext_refresh) after it edits
457
+ // extension files, so the user can reload extensions with one click.
458
+ _appendExtRefreshHint();
459
+ break;
460
+
441
461
  }
442
462
  });
443
463
 
444
- // ── Warning transformation ─────────────────────────────────────────────────
464
+ // ── Ext-developer refresh hint ─────────────────────────────────────────────
465
+ // Appended after a completed ext-developer session so the user can reload
466
+ // extensions with one click instead of doing a manual page refresh.
467
+
468
+ function _appendExtRefreshHint() {
469
+ const messages = RenderTarget.current();
470
+ if (!messages) return;
471
+
472
+ const wrap = document.createElement("div");
473
+ wrap.className = "msg msg-ext-refresh-hint";
474
+
475
+ const btn = document.createElement("button");
476
+ btn.className = "btn-ext-refresh";
477
+ btn.innerHTML =
478
+ `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` +
479
+ `<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>` +
480
+ `<path d="M21 3v5h-5"/>` +
481
+ `<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>` +
482
+ `<path d="M8 16H3v5"/>` +
483
+ `</svg>` +
484
+ `<span>${I18n.t("ext.refresh_hint")}</span>`;
485
+ btn.onclick = () => {
486
+ if (window.Clacky && Clacky.ext && typeof Clacky.ext.reload === "function") {
487
+ Clacky.ext.reload();
488
+ } else {
489
+ location.reload();
490
+ }
491
+ btn.disabled = true;
492
+ btn.style.opacity = "0.5";
493
+ };
494
+
495
+ wrap.appendChild(btn);
496
+ messages.appendChild(wrap);
497
+
498
+ // Scroll into view
499
+ messages.scrollTop = messages.scrollHeight;
500
+ }
501
+
502
+
445
503
 
446
504
  function _transformRetryWarning(message) {
447
505
  return message;
@@ -103,13 +103,21 @@ main() {
103
103
 
104
104
  assert_supported_os
105
105
 
106
+ # --clt-only: install just what ships /usr/bin/python3 (Xcode CLT on macOS,
107
+ # python3 on Linux) and skip Homebrew. Callers that only need python3 (e.g.
108
+ # the file parser) use this to avoid the interactive Homebrew sudo prompt.
109
+ local clt_only=false
110
+ [ "$1" = "--clt-only" ] && clt_only=true
111
+
106
112
  case "$OS" in
107
113
  macOS)
108
114
  detect_shell
109
115
  ensure_xcode_clt || exit 1
110
- detect_network_region
111
- configure_homebrew_cn_mirrors
112
- ensure_homebrew || exit 1
116
+ if [ "$clt_only" = false ]; then
117
+ detect_network_region
118
+ configure_homebrew_cn_mirrors
119
+ ensure_homebrew || exit 1
120
+ fi
113
121
  ;;
114
122
  Linux) ensure_linux_deps || exit 1 ;;
115
123
  esac
@@ -599,13 +599,21 @@ main() {
599
599
 
600
600
  assert_supported_os
601
601
 
602
+ # --clt-only: install just what ships /usr/bin/python3 (Xcode CLT on macOS,
603
+ # python3 on Linux) and skip Homebrew. Callers that only need python3 (e.g.
604
+ # the file parser) use this to avoid the interactive Homebrew sudo prompt.
605
+ local clt_only=false
606
+ [ "$1" = "--clt-only" ] && clt_only=true
607
+
602
608
  case "$OS" in
603
609
  macOS)
604
610
  detect_shell
605
611
  ensure_xcode_clt || exit 1
606
- detect_network_region
607
- configure_homebrew_cn_mirrors
608
- ensure_homebrew || exit 1
612
+ if [ "$clt_only" = false ]; then
613
+ detect_network_region
614
+ configure_homebrew_cn_mirrors
615
+ ensure_homebrew || exit 1
616
+ fi
609
617
  ;;
610
618
  Linux) ensure_linux_deps || exit 1 ;;
611
619
  esac
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.5.2
4
+ version: 1.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -293,6 +293,7 @@ files:
293
293
  - ".clacky/skills/gem-release/scripts/release.sh"
294
294
  - ".clacky/skills/oss-upload/SKILL.md"
295
295
  - ".clackyrules"
296
+ - ".dockerignore"
296
297
  - ".rspec"
297
298
  - ".rubocop.yml"
298
299
  - CHANGELOG.md
@@ -391,7 +392,7 @@ files:
391
392
  - lib/clacky/default_parsers/pdf_parser_vlm.py
392
393
  - lib/clacky/default_parsers/pptx_parser.rb
393
394
  - lib/clacky/default_parsers/wps_parser.rb
394
- - lib/clacky/default_parsers/xlsx_parser.rb
395
+ - lib/clacky/default_parsers/xlsx_parser.py
395
396
  - lib/clacky/default_skills/browser-setup/SKILL.md
396
397
  - lib/clacky/default_skills/channel-manager/SKILL.md
397
398
  - lib/clacky/default_skills/channel-manager/dingtalk_setup.rb
@@ -536,6 +537,7 @@ files:
536
537
  - lib/clacky/server/epipe_safe_io.rb
537
538
  - lib/clacky/server/git_panel.rb
538
539
  - lib/clacky/server/http_server.rb
540
+ - lib/clacky/server/project_manager.rb
539
541
  - lib/clacky/server/scheduler.rb
540
542
  - lib/clacky/server/server_master.rb
541
543
  - lib/clacky/server/session_registry.rb
@@ -662,6 +664,7 @@ files:
662
664
  - lib/clacky/web/icon.svg
663
665
  - lib/clacky/web/index.html
664
666
  - lib/clacky/web/logo_nav_dark.png
667
+ - lib/clacky/web/projects.js
665
668
  - lib/clacky/web/sessions.js
666
669
  - lib/clacky/web/settings.js
667
670
  - lib/clacky/web/skills.js
@@ -1,121 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
- #
4
- # Clacky XLSX Parser — CLI interface
5
- #
6
- # Usage:
7
- # ruby xlsx_parser.rb <file_path>
8
- #
9
- # Output:
10
- # stdout — extracted content in Markdown tables (UTF-8)
11
- # stderr — error messages
12
- # exit 0 — success
13
- # exit 1 — failure
14
- #
15
- # Dependencies: rubyzip gem (gem install rubyzip)
16
- #
17
- # This file lives in ~/.clacky/parsers/ and can be modified by the LLM.
18
- #
19
- # VERSION: 1
20
-
21
- require "zip"
22
- require "rexml/document"
23
- require "stringio"
24
-
25
- def parse_row(row_node, shared_strings)
26
- REXML::XPath.match(row_node, ".//c").map do |c|
27
- v = REXML::XPath.first(c, "v")&.text
28
- next "" unless v
29
- c.attributes["t"] == "s" ? (shared_strings[v.to_i] || "") : v
30
- end
31
- end
32
-
33
- def build_markdown_table(rows)
34
- col_count = rows.map(&:size).max
35
- lines = []
36
- rows.each_with_index do |row, i|
37
- padded = row + [""] * [col_count - row.size, 0].max
38
- lines << "| #{padded.join(" | ")} |"
39
- lines << "|#{" --- |" * col_count}" if i == 0
40
- end
41
- lines.join("\n")
42
- end
43
-
44
- # --- main ---
45
-
46
- path = ARGV[0]
47
-
48
- if path.nil? || path.empty?
49
- warn "Usage: ruby xlsx_parser.rb <file_path>"
50
- exit 1
51
- end
52
-
53
- unless File.exist?(path)
54
- warn "File not found: #{path}"
55
- exit 1
56
- end
57
-
58
- begin
59
- body = File.binread(path)
60
- shared_strings = []
61
- sheet_names = {}
62
- sheet_xmls = {}
63
-
64
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
65
- ss_entry = zip.find_entry("xl/sharedStrings.xml")
66
- if ss_entry
67
- doc = REXML::Document.new(ss_entry.get_input_stream.read)
68
- REXML::XPath.each(doc, "//si") do |si|
69
- shared_strings << REXML::XPath.match(si, ".//t").map(&:text).compact.join
70
- end
71
- end
72
-
73
- wb_entry = zip.find_entry("xl/workbook.xml")
74
- if wb_entry
75
- doc = REXML::Document.new(wb_entry.get_input_stream.read)
76
- REXML::XPath.each(doc, "//sheet") do |s|
77
- idx = s.attributes["sheetId"]
78
- name = s.attributes["name"]
79
- sheet_names[idx] = name if idx && name
80
- end
81
- end
82
-
83
- zip.each do |entry|
84
- if entry.name =~ %r{xl/worksheets/sheet(\d+)\.xml}
85
- sheet_xmls[$1] = entry.get_input_stream.read
86
- end
87
- end
88
- end
89
-
90
- if sheet_xmls.empty?
91
- warn "Spreadsheet appears to be empty"
92
- exit 1
93
- end
94
-
95
- sections = []
96
- sheet_xmls.keys.sort_by(&:to_i).each do |idx|
97
- name = sheet_names[idx] || "Sheet#{idx}"
98
- doc = REXML::Document.new(sheet_xmls[idx])
99
-
100
- rows = []
101
- REXML::XPath.each(doc, "//row") do |row|
102
- cells = parse_row(row, shared_strings)
103
- rows << cells unless cells.all?(&:empty?)
104
- end
105
-
106
- next if rows.empty?
107
- sections << "### #{name}\n\n#{build_markdown_table(rows)}"
108
- end
109
-
110
- if sections.empty?
111
- warn "Spreadsheet appears to be empty"
112
- exit 1
113
- end
114
-
115
- print sections.join("\n\n")
116
- exit 0
117
- rescue => e
118
- warn "Failed to parse XLSX: #{e.message}"
119
- warn "Tip: ensure rubyzip is installed: gem install rubyzip"
120
- exit 1
121
- end