mulmoclaude 2.6.0 → 2.8.0

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 (86) hide show
  1. package/client/assets/Graph-ChAxkn9o.js +1 -0
  2. package/client/assets/{JsonEditor-FKfzzMkt.js → JsonEditor-COQcmjLQ.js} +1 -1
  3. package/client/assets/dist-Bij44LdC.js +2 -0
  4. package/client/assets/echarts-GV3U9sVf.js +1 -0
  5. package/client/assets/echarts-gl-BXx4On0Y.js +4282 -0
  6. package/client/assets/echarts-wordcloud-_9uQUAfp.js +1 -0
  7. package/client/assets/emscripten-module-Q67P5WYC-BU3Hlttz.js +2160 -0
  8. package/client/assets/ffi-4V2b7xu3.js +1 -0
  9. package/client/assets/{html2canvas-D0m9Y8pD-y1tDAhR3.js → html2canvas-NBfz6Rb9-CIdEBoH2.js} +1 -1
  10. package/client/assets/{index-gnbD2OHU.js → index-D6xzCZhP.js} +605 -604
  11. package/client/assets/index-DzMsXDnj.css +2 -0
  12. package/client/assets/{index.es-HSMDtZ_a-CYtNwG_G.js → index.es-STZX6pYP-BGvt3MI2.js} +3 -3
  13. package/client/assets/install-C1moNcrn.js +15 -0
  14. package/client/assets/japan-CyDc-cz7-BgORDJsl.js +1 -0
  15. package/client/assets/{marp-BVojbGre.js → marp-Du5Sym5b.js} +57 -57
  16. package/client/assets/module-ES6BEMUI-CDTIpMiA.js +6 -0
  17. package/client/assets/purify.es-DkTnAgLw-BZxk_wWO.js +3 -0
  18. package/client/assets/style-BZwVK1dO-BFtilmZh.js +1 -0
  19. package/client/assets/vue-i18n-YREco4q1.js +3 -0
  20. package/client/assets/world-R-tMZ4nZ-B1c4T9X2.js +1 -0
  21. package/client/index.html +6 -4
  22. package/package.json +13 -12
  23. package/server/api/routes/plugins.ts +9 -59
  24. package/server/build/mcp-server.mjs +806 -714
  25. package/server/index.ts +1 -0
  26. package/server/plugins/confined-file-ops.ts +56 -0
  27. package/server/plugins/spreadsheet-builtin.ts +26 -0
  28. package/server/plugins/spreadsheet-save-outcome.ts +7 -0
  29. package/server/utils/files/store-resolvers.ts +1 -1
  30. package/src/lang/de.ts +0 -20
  31. package/src/lang/en.ts +0 -20
  32. package/src/lang/es.ts +0 -20
  33. package/src/lang/fr.ts +0 -20
  34. package/src/lang/ja.ts +0 -20
  35. package/src/lang/ko.ts +0 -20
  36. package/src/lang/pt-BR.ts +0 -20
  37. package/src/lang/zh.ts +0 -20
  38. package/src/plugins/spreadsheet/definition.ts +9 -127
  39. package/src/plugins/spreadsheet/index.ts +7 -4
  40. package/src/plugins/spreadsheet/meta.ts +2 -4
  41. package/client/assets/index-BA0fpWIZ.css +0 -2
  42. package/client/assets/purify.es-BaSjDiWD-Dbm5oOcm.js +0 -3
  43. package/client/assets/style-CCV5GAnU-D55tgtzm.js +0 -1
  44. package/client/assets/vue-i18n-2bgWpywp.js +0 -3
  45. package/server/utils/files/spreadsheet-store.ts +0 -26
  46. package/src/plugins/spreadsheet/Preview.vue +0 -31
  47. package/src/plugins/spreadsheet/View.vue +0 -1007
  48. package/src/plugins/spreadsheet/cellHighlights.ts +0 -78
  49. package/src/plugins/spreadsheet/engine/calculator.ts +0 -427
  50. package/src/plugins/spreadsheet/engine/cellBuilder.ts +0 -89
  51. package/src/plugins/spreadsheet/engine/cellEmpty.ts +0 -26
  52. package/src/plugins/spreadsheet/engine/cellFormatting.ts +0 -59
  53. package/src/plugins/spreadsheet/engine/coerce-boolean.ts +0 -26
  54. package/src/plugins/spreadsheet/engine/condition.ts +0 -182
  55. package/src/plugins/spreadsheet/engine/date-locale.ts +0 -47
  56. package/src/plugins/spreadsheet/engine/date-parser.ts +0 -234
  57. package/src/plugins/spreadsheet/engine/date-utils.ts +0 -73
  58. package/src/plugins/spreadsheet/engine/datedif.ts +0 -93
  59. package/src/plugins/spreadsheet/engine/engine.ts +0 -177
  60. package/src/plugins/spreadsheet/engine/evaluator.ts +0 -494
  61. package/src/plugins/spreadsheet/engine/financial-math.ts +0 -114
  62. package/src/plugins/spreadsheet/engine/formatter.ts +0 -158
  63. package/src/plugins/spreadsheet/engine/formulaError.ts +0 -67
  64. package/src/plugins/spreadsheet/engine/formulaRefs.ts +0 -202
  65. package/src/plugins/spreadsheet/engine/functions/date.ts +0 -212
  66. package/src/plugins/spreadsheet/engine/functions/financial.ts +0 -244
  67. package/src/plugins/spreadsheet/engine/functions/index.ts +0 -16
  68. package/src/plugins/spreadsheet/engine/functions/logical.ts +0 -221
  69. package/src/plugins/spreadsheet/engine/functions/lookup-math.ts +0 -28
  70. package/src/plugins/spreadsheet/engine/functions/lookup.ts +0 -257
  71. package/src/plugins/spreadsheet/engine/functions/mathematical.ts +0 -283
  72. package/src/plugins/spreadsheet/engine/functions/statistical-math.ts +0 -91
  73. package/src/plugins/spreadsheet/engine/functions/statistical.ts +0 -290
  74. package/src/plugins/spreadsheet/engine/functions/text.ts +0 -398
  75. package/src/plugins/spreadsheet/engine/index.ts +0 -34
  76. package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +0 -111
  77. package/src/plugins/spreadsheet/engine/math-ops.ts +0 -98
  78. package/src/plugins/spreadsheet/engine/numericCoercion.ts +0 -61
  79. package/src/plugins/spreadsheet/engine/parser.ts +0 -40
  80. package/src/plugins/spreadsheet/engine/registry.ts +0 -201
  81. package/src/plugins/spreadsheet/engine/responseDecoder.ts +0 -68
  82. package/src/plugins/spreadsheet/engine/spreadsheet-errors.ts +0 -82
  83. package/src/plugins/spreadsheet/engine/textFormat.ts +0 -108
  84. package/src/plugins/spreadsheet/engine/translateFormula.ts +0 -69
  85. package/src/plugins/spreadsheet/engine/types.ts +0 -81
  86. package/src/plugins/spreadsheet/keyboardNav.ts +0 -38
@@ -1,1007 +0,0 @@
1
- <template>
2
- <div class="spreadsheet-container">
3
- <div v-if="loading" class="min-h-full p-8 flex items-center justify-center">
4
- <div class="text-gray-500">{{ t("pluginSpreadsheet.loading") }}</div>
5
- </div>
6
- <div v-else-if="errorMessage" class="min-h-full p-8 flex items-center justify-center">
7
- <div class="error">{{ errorMessage }}</div>
8
- </div>
9
- <div v-else-if="!resolvedSheets || resolvedSheets.length === 0" class="min-h-full p-8 flex items-center justify-center">
10
- <div class="text-gray-500">{{ t("pluginSpreadsheet.noData") }}</div>
11
- </div>
12
- <template v-else>
13
- <div class="spreadsheet-content-wrapper">
14
- <div class="p-4">
15
- <div class="header">
16
- <h1 class="title">
17
- {{ selectedResult.title || t("pluginSpreadsheet.untitled") }}
18
- </h1>
19
- <!-- `download-btn` class kept as a name hook — referenced by
20
- e2e/tests/spreadsheet.spec.ts via `button.download-btn`.
21
- Styling is inline Tailwind; the class has no CSS rules. -->
22
- <button
23
- class="download-btn h-8 px-2.5 flex items-center gap-1 rounded bg-[#217346] hover:bg-[#1e6a3f] active:bg-[#1a5c36] text-white text-sm transition-colors"
24
- @click="downloadExcel"
25
- >
26
- <span class="material-icons text-base">download</span>
27
- {{ t("pluginSpreadsheet.excel") }}
28
- </button>
29
- </div>
30
-
31
- <!-- Sheet tabs (if multiple sheets) -->
32
- <div v-if="resolvedSheets.length > 1" class="sheet-tabs">
33
- <button
34
- v-for="(sheet, index) in resolvedSheets"
35
- :key="index"
36
- :class="['sheet-tab', { active: activeSheetIndex === index }]"
37
- @click="activeSheetIndex = index"
38
- >
39
- {{ sheet.name }}
40
- </button>
41
- </div>
42
-
43
- <!-- Spreadsheet table -->
44
- <!-- eslint-disable-next-line vue/no-v-html -- XLSX.utils.sheet_to_html output of app-owned sheet data; trusted in-process render -->
45
- <div ref="tableContainer" class="table-container" @click="handleTableClick" v-html="renderedHtml"></div>
46
- </div>
47
- </div>
48
-
49
- <!-- Collapsible Editor -->
50
- <details v-if="!miniEditorOpen" ref="editorDetails" class="spreadsheet-source">
51
- <summary>{{ t("pluginSpreadsheet.editData") }}</summary>
52
- <textarea ref="editorTextarea" v-model="editableData" class="spreadsheet-editor" spellcheck="false" @input="handleDataEdit"></textarea>
53
- <button class="apply-btn" :disabled="!hasChanges" @click="applyChanges">{{ t("pluginSpreadsheet.applyChanges") }}</button>
54
- </details>
55
-
56
- <!-- Mini Editor at Bottom -->
57
- <div v-if="miniEditorOpen" class="mini-editor-panel">
58
- <div class="mini-editor-content">
59
- <span v-if="miniEditorCell" class="cell-ref"> {{ indexToCol(miniEditorCell.col) }}{{ miniEditorCell.row + 1 }} </span>
60
-
61
- <!-- Type Selector -->
62
- <div class="radio-group">
63
- <label class="radio-option">
64
- <input v-model="miniEditorType" type="radio" value="string" />
65
- {{ t("pluginSpreadsheet.stringType") }}
66
- </label>
67
- <label class="radio-option">
68
- <input v-model="miniEditorType" type="radio" value="object" />
69
- {{ t("pluginSpreadsheet.formulaType") }}
70
- </label>
71
- </div>
72
-
73
- <!-- String input -->
74
- <input
75
- v-if="miniEditorType === 'string'"
76
- v-model="miniEditorValue"
77
- type="text"
78
- class="form-input"
79
- :placeholder="t('pluginSpreadsheet.valuePlaceholder')"
80
- @keyup.enter="saveMiniEditor"
81
- />
82
-
83
- <!-- Formula inputs -->
84
- <template v-if="miniEditorType === 'object'">
85
- <input
86
- v-model="miniEditorFormula"
87
- type="text"
88
- class="form-input"
89
- :placeholder="t('pluginSpreadsheet.valueOrFormulaPlaceholder')"
90
- @keyup.enter="saveMiniEditor"
91
- />
92
- <input
93
- v-model="miniEditorFormat"
94
- type="text"
95
- class="form-input"
96
- :placeholder="t('pluginSpreadsheet.formatPlaceholder')"
97
- @keyup.enter="saveMiniEditor"
98
- />
99
- </template>
100
-
101
- <button class="save-btn" @click="saveMiniEditor">{{ t("pluginSpreadsheet.update") }}</button>
102
- <button class="cancel-btn" @click="closeMiniEditor">✕</button>
103
- </div>
104
- </div>
105
- </template>
106
- </div>
107
- </template>
108
-
109
- <script setup lang="ts">
110
- import { computed, ref, watch, onMounted, onUnmounted } from "vue";
111
- import { useI18n } from "vue-i18n";
112
- import * as XLSX from "xlsx";
113
- import type { ToolResult } from "gui-chat-protocol";
114
- import type { SpreadsheetToolData, SpreadsheetSheet, SpreadsheetCellValue, StorableCell, SpreadsheetEndpoints } from "./definition";
115
- import {
116
- SpreadsheetEngine,
117
- prefersDayFirst,
118
- indexToColumn,
119
- extractCellReferences,
120
- buildCellFromInput,
121
- normalizeData,
122
- decodeSpreadsheetResponse,
123
- findCellJsonPosition,
124
- type SpreadsheetCell,
125
- type SheetData,
126
- type CellValue,
127
- } from "./engine";
128
- import { applyCellHighlights, clearCellHighlights } from "./cellHighlights";
129
- import { getArrowKeyOffset, isWithinSheetBounds } from "./keyboardNav";
130
- import { handleExternalLinkClick } from "@mulmoclaude/markdown-utils/dom/externalLink";
131
- import { errorMessage as formatErrorMessage } from "../../utils/errors";
132
- import { escapeHtml } from "@mulmoclaude/core/wiki";
133
-
134
- // Import all spreadsheet functions to populate the function registry
135
- import "./engine/functions";
136
- import { apiGet, apiPut } from "../../utils/api";
137
- import { pluginEndpoints } from "../api";
138
- import type { FilesContentResponseLike } from "./engine/responseDecoder";
139
- import { isObj, isRecord, isUnknownArray } from "../../utils/types";
140
-
141
- const endpoints = pluginEndpoints<SpreadsheetEndpoints>("spreadsheet");
142
- const filesEndpoints = pluginEndpoints<{ content: string }>("files");
143
-
144
- const { t, locale } = useI18n();
145
-
146
- /**
147
- * Normalize malformed data structures
148
- * Some models generate flat arrays instead of 2D arrays - fix them
149
- */
150
-
151
- // Cells can be raw LLM output of arbitrary shape; tightening here would cascade through the engine API.
152
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
- function normalizeSheetData(data: any): any[][] {
154
- // Handle null/undefined
155
- if (!data) {
156
- return [];
157
- }
158
-
159
- // If not an array
160
- if (!Array.isArray(data)) {
161
- return [];
162
- }
163
-
164
- // Empty array
165
- if (data.length === 0) {
166
- return [];
167
- }
168
-
169
- // If data is already a 2D array, return as-is
170
- if (Array.isArray(data[0])) {
171
- return data;
172
- }
173
-
174
- // If data is a flat array of cell objects, convert to 2D by pairing cells
175
- // Pattern: [cell1, cell2, cell3, cell4] -> [[cell1, cell2], [cell3, cell4]]
176
- if (isObj(data[0])) {
177
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
- const rows: any[][] = [];
179
- for (let i = 0; i < data.length; i += 2) {
180
- const row = [data[i]];
181
- if (i + 1 < data.length) {
182
- row.push(data[i + 1]);
183
- }
184
- rows.push(row);
185
- }
186
- return rows;
187
- }
188
-
189
- // Unknown structure - return empty
190
- return [];
191
- }
192
-
193
- const props = defineProps<{
194
- selectedResult: ToolResult<SpreadsheetToolData>;
195
- }>();
196
-
197
- const emit = defineEmits<{
198
- updateResult: [result: ToolResult];
199
- }>();
200
-
201
- // Create spreadsheet engine instance
202
- const engine = new SpreadsheetEngine();
203
-
204
- const loading = ref(false);
205
- const errorMessage = ref("");
206
- const resolvedSheets = ref<SpreadsheetSheet[]>([]);
207
-
208
- // Accepts only the canonical prefix. Legacy `spreadsheets/*.json`
209
- // references in old session JSONL are no longer auto-resolved —
210
- // those sessions render the file path as plain text.
211
- function isFilePath(value: unknown): value is string {
212
- return typeof value === "string" && value.startsWith("artifacts/spreadsheets/") && value.endsWith(".json");
213
- }
214
-
215
- const isCellValue = (value: unknown): value is SpreadsheetCellValue => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
216
-
217
- // Each predicate claims exactly the union member it proves: `StorableCell`
218
- // covers the four forms a stored workbook holds — `{ v, f }`, an empty `{}` or
219
- // `{ v: null }`, a bare value, and a `null` gap — because `normalizeData`
220
- // folds all four and a reader that took only `{ v }` would reject workbooks
221
- // the app renders today.
222
- const isStorableCell = (value: unknown): value is StorableCell =>
223
- value === null ||
224
- isCellValue(value) ||
225
- (isRecord(value) && (value.v === undefined || value.v === null || isCellValue(value.v)) && (value.f === undefined || typeof value.f === "string"));
226
-
227
- const isCellRow = (value: unknown): value is StorableCell[] => isUnknownArray(value) && value.every(isStorableCell);
228
-
229
- const isSheet = (value: unknown): value is SpreadsheetSheet =>
230
- isRecord(value) && typeof value.name === "string" && isUnknownArray(value.data) && value.data.every(isCellRow);
231
-
232
- /** Sheets replayed from session JSONL, where the declared type is a claim
233
- * about whatever wrote the log rather than about this value. Taken whole or
234
- * rejected whole — dropping the unreadable half of a workbook would hide
235
- * part of a user's file behind cells that merely look empty. */
236
- function readReplayedSheets(value: unknown): SpreadsheetSheet[] | null {
237
- return isUnknownArray(value) && value.every(isSheet) ? value : null;
238
- }
239
-
240
- // Editor textarea backing ref. Declared up here (rather than next to
241
- // the other view-state refs further down) so the `fetchSheets().then`
242
- // initializer below can assign to it without TDZ.
243
- const editableData = ref(JSON.stringify(resolvedSheets.value || [], null, 2));
244
-
245
- async function fetchSheets(): Promise<void> {
246
- const raw = props.selectedResult.data?.sheets;
247
- // Clear any stale error from a previous result BEFORE the early
248
- // returns, otherwise switching from a failed file-backed load to
249
- // a new inline-data result leaves the error panel on screen.
250
- errorMessage.value = "";
251
- if (!raw) {
252
- resolvedSheets.value = [];
253
- return;
254
- }
255
- if (!isFilePath(raw)) {
256
- // Legacy inline data
257
- const inline = readReplayedSheets(raw);
258
- resolvedSheets.value = inline ?? [];
259
- if (!inline) errorMessage.value = t("pluginSpreadsheet.dataMustBeArray");
260
- return;
261
- }
262
- loading.value = true;
263
- const response = await apiGet<FilesContentResponseLike>(filesEndpoints.content, { path: raw });
264
- if (!response.ok) {
265
- errorMessage.value = t("pluginSpreadsheet.loadFailed", { error: response.error });
266
- resolvedSheets.value = [];
267
- loading.value = false;
268
- return;
269
- }
270
- // The /files/content endpoint returns { kind, content?, message? }.
271
- // Delegate the shape/validation decision to decodeSpreadsheetResponse
272
- // so the async wrapper stays simple.
273
- const result = decodeSpreadsheetResponse(response.data);
274
- if (result.kind === "error") {
275
- errorMessage.value = result.message;
276
- resolvedSheets.value = [];
277
- } else {
278
- resolvedSheets.value = result.sheets;
279
- }
280
- loading.value = false;
281
- }
282
-
283
- // Fetch on mount and sync editableData
284
- fetchSheets().then(() => {
285
- editableData.value = JSON.stringify(resolvedSheets.value || [], null, 2);
286
- });
287
-
288
- /** Persist edited sheets to disk when file-backed, and emit updateResult. */
289
- async function persistSheets(sheets: SpreadsheetSheet[]): Promise<void> {
290
- const raw = props.selectedResult.data?.sheets;
291
- if (isFilePath(raw)) {
292
- // Send the full workspace-relative path so the route doesn't have
293
- // to reconstruct one from a basename — paths under
294
- // `artifacts/spreadsheets/` are now sharded by YYYY/MM (#764).
295
- const result = await apiPut<unknown>(endpoints.update.url, {
296
- relativePath: raw,
297
- sheets,
298
- });
299
- if (!result.ok) {
300
- errorMessage.value = `Failed to save spreadsheet: ${result.error}`;
301
- return;
302
- }
303
- }
304
-
305
- resolvedSheets.value = sheets;
306
-
307
- const updatedResult: ToolResult<SpreadsheetToolData> = {
308
- ...props.selectedResult,
309
- data: {
310
- ...props.selectedResult.data,
311
- sheets: isFilePath(raw) ? raw : sheets,
312
- },
313
- };
314
- emit("updateResult", updatedResult);
315
- }
316
-
317
- const activeSheetIndex = ref(0);
318
- // Editor state declared together with the other refs above. Seeded
319
- // in the on-mount `fetchSheets().then(...)` block above (the `.value`
320
- // assignment is in scope by the time that .then callback fires).
321
- const editorTextarea = ref<HTMLTextAreaElement | null>(null);
322
- const editorDetails = ref<HTMLDetailsElement | null>(null);
323
- const tableContainer = ref<HTMLDivElement | null>(null);
324
-
325
- // Mini editor state
326
- const miniEditorOpen = ref(false);
327
- const miniEditorCell = ref<{ row: number; col: number } | null>(null);
328
-
329
- const miniEditorValue = ref<unknown>(null);
330
- const miniEditorType = ref<"number" | "string" | "object">("string");
331
- const miniEditorFormula = ref("");
332
- const miniEditorFormat = ref("");
333
-
334
- // Referenced cells state (for formula highlighting)
335
- const referencedCells = ref<{ row: number; col: number }[]>([]);
336
-
337
- // Check if spreadsheet data has been modified
338
- const hasChanges = computed(() => {
339
- try {
340
- const currentData = JSON.stringify(resolvedSheets.value || [], null, 2);
341
- return editableData.value !== currentData;
342
- } catch {
343
- return false;
344
- }
345
- });
346
-
347
- // Short alias used in the template column header.
348
- const indexToCol = indexToColumn;
349
-
350
- // `normalizeData` is the engine's own reader for the stored cell forms (bare
351
- // values, `null` gaps, flat rows); `calculate` runs it internally anyway, so
352
- // applying it here only meets the engine's declared type at the call site.
353
- const toEngineSheet = (sheet: SpreadsheetSheet): SheetData => ({ name: sheet.name, data: normalizeData(sheet.data) });
354
-
355
- // Calculate formulas in the data using the spreadsheet engine
356
- const calculateFormulas = (data: StorableCell[][], sheetName?: string): CellValue[][] => {
357
- // If we have a sheet name, we need to find all sheets for cross-sheet references
358
- const allSheets = resolvedSheets.value.map(toEngineSheet);
359
-
360
- // Create a SheetData object for the engine
361
- const sheet: SheetData = {
362
- name: sheetName || "Sheet1",
363
- data: normalizeData(data),
364
- };
365
-
366
- // Read the date order from the current locale on every calculation, so a
367
- // locale switch takes effect without rebuilding the engine.
368
- engine.setOptions({ preferDDMMYYYY: prefersDayFirst(locale.value) });
369
- const result = engine.calculate(sheet, allSheets);
370
-
371
- // Return the calculated data
372
-
373
- return result.data;
374
- };
375
-
376
- // Render the active sheet as HTML table
377
- const renderedHtml = computed(() => {
378
- if (!resolvedSheets.value || resolvedSheets.value.length === 0) {
379
- return "";
380
- }
381
-
382
- const sheet = resolvedSheets.value[activeSheetIndex.value];
383
- if (!sheet || !sheet.data) {
384
- return "";
385
- }
386
-
387
- try {
388
- // Calculate formulas first with sheet name for cross-sheet references
389
- const calculatedData = calculateFormulas(sheet.data, sheet.name);
390
-
391
- // Convert data array to worksheet
392
- const worksheet = XLSX.utils.aoa_to_sheet(calculatedData);
393
-
394
- // Generate HTML table
395
- const html = XLSX.utils.sheet_to_html(worksheet, {
396
- id: "spreadsheet-table",
397
- editable: false,
398
- });
399
-
400
- return html;
401
- } catch (error) {
402
- console.error("Failed to render spreadsheet:", error);
403
- // Result is fed into v-html (see :data-testid="table"). The
404
- // message comes from a caught throw, which can include attacker-
405
- // controlled object shapes (e.g. `{ details: "<script>..." }`)
406
- // surfaced by formatErrorMessage's gRPC-style unwrap. Escape
407
- // before interpolation. (Codex review on #1767.)
408
- return `<div class="error">Failed to render spreadsheet: ${escapeHtml(formatErrorMessage(error, "Unknown error"))}</div>`;
409
- }
410
- });
411
-
412
- // Download as Excel file
413
- const downloadExcel = () => {
414
- if (!resolvedSheets.value || resolvedSheets.value.length === 0) return;
415
-
416
- try {
417
- const workbook = XLSX.utils.book_new();
418
-
419
- // Add all sheets to workbook
420
- resolvedSheets.value.forEach((sheet) => {
421
- const worksheet = XLSX.utils.aoa_to_sheet(sheet.data);
422
- XLSX.utils.book_append_sheet(workbook, worksheet, sheet.name);
423
- });
424
-
425
- // Generate filename
426
- const filename = props.selectedResult.title ? `${props.selectedResult.title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}.xlsx` : "spreadsheet.xlsx";
427
-
428
- // Write file
429
- XLSX.writeFile(workbook, filename);
430
- } catch (error) {
431
- console.error("Failed to download Excel:", error);
432
- alert(`Failed to download Excel file: ${formatErrorMessage(error, "Unknown error")}`);
433
- }
434
- };
435
-
436
- function handleDataEdit() {
437
- // Just update the local state, don't apply yet
438
- // User needs to click "Apply Changes" button
439
- }
440
-
441
- // `extractCellReferences` now lives in `./engine/formulaRefs.ts`
442
- // (imported at the top of this file). Extracted to bring this
443
- // file's cognitive complexity back under the sonarjs threshold
444
- // and to make the formula-reference scanner unit-testable.
445
- // See `test/plugins/spreadsheet/engine/test_formulaRefs.ts`.
446
-
447
- function openMiniEditor(rowIndex: number, colIndex: number) {
448
- try {
449
- const sheets = JSON.parse(editableData.value);
450
- const currentSheet = sheets[activeSheetIndex.value];
451
-
452
- if (!currentSheet || !currentSheet.data) {
453
- return;
454
- }
455
-
456
- // Normalize the data in case it's malformed
457
- const normalizedData = normalizeSheetData(currentSheet.data);
458
-
459
- if (!normalizedData[rowIndex] || normalizedData[rowIndex][colIndex] === undefined) {
460
- return;
461
- }
462
-
463
- const cellValue = normalizedData[rowIndex][colIndex];
464
-
465
- // Determine cell type and extract values (new format: {v, f})
466
- if (isRecord(cellValue) && "v" in cellValue) {
467
- const value = cellValue.v;
468
- const format = typeof cellValue.f === "string" ? cellValue.f : "";
469
-
470
- // Check if it's a formula (value starts with "=")
471
- if (typeof value === "string" && value.startsWith("=")) {
472
- miniEditorType.value = "object";
473
- miniEditorValue.value = "";
474
- miniEditorFormula.value = value.substring(1); // Remove "=" prefix
475
- miniEditorFormat.value = format;
476
- // Extract and store referenced cells for highlighting
477
- referencedCells.value = extractCellReferences(value);
478
- } else if (typeof value === "number") {
479
- miniEditorType.value = "object";
480
- miniEditorValue.value = "";
481
- miniEditorFormula.value = String(value);
482
- miniEditorFormat.value = format;
483
- referencedCells.value = [];
484
- } else {
485
- miniEditorType.value = "string";
486
- miniEditorValue.value = String(value);
487
- miniEditorFormula.value = "";
488
- miniEditorFormat.value = "";
489
- referencedCells.value = [];
490
- }
491
- } else {
492
- // Legacy format or plain value
493
- miniEditorType.value = "string";
494
- miniEditorValue.value = String(cellValue ?? "");
495
- miniEditorFormula.value = "";
496
- miniEditorFormat.value = "";
497
- referencedCells.value = [];
498
- }
499
-
500
- miniEditorCell.value = { row: rowIndex, col: colIndex };
501
- miniEditorOpen.value = true;
502
- } catch (error) {
503
- console.error("Failed to open mini editor:", error);
504
- }
505
- }
506
-
507
- function closeMiniEditor() {
508
- miniEditorOpen.value = false;
509
- miniEditorCell.value = null;
510
- miniEditorValue.value = null;
511
- miniEditorFormula.value = "";
512
- miniEditorFormat.value = "";
513
- referencedCells.value = [];
514
- }
515
-
516
- function saveMiniEditor() {
517
- if (!miniEditorCell.value) return;
518
-
519
- try {
520
- const sheets = JSON.parse(editableData.value);
521
- const currentSheet = sheets[activeSheetIndex.value];
522
-
523
- if (!currentSheet || !currentSheet.data) return;
524
-
525
- const { row, col } = miniEditorCell.value;
526
-
527
- // Normalize the data in case it's malformed
528
- const normalizedData = normalizeSheetData(currentSheet.data);
529
-
530
- // Ensure the row exists
531
- while (normalizedData.length <= row) {
532
- normalizedData.push([]);
533
- }
534
-
535
- // Ensure the row is an array
536
- if (!Array.isArray(normalizedData[row])) {
537
- normalizedData[row] = [];
538
- }
539
-
540
- // Build the new cell value (delegates formula/number detection
541
- // and format attachment to the pure helper).
542
- const newCellValue: SpreadsheetCell = buildCellFromInput({
543
- type: miniEditorType.value,
544
- value: miniEditorValue.value,
545
- formula: miniEditorFormula.value,
546
- format: miniEditorFormat.value,
547
- });
548
-
549
- // Update the cell in normalized data
550
- normalizedData[row][col] = newCellValue;
551
-
552
- // Update the sheet with normalized data
553
- currentSheet.data = normalizedData;
554
-
555
- // Update editableData
556
- editableData.value = JSON.stringify(sheets, null, 2);
557
-
558
- // Persist to disk (if file-backed) and emit update
559
- persistSheets(sheets);
560
-
561
- // Update referenced cells if the saved cell contains a formula
562
- if (typeof newCellValue.v === "string" && newCellValue.v.startsWith("=")) {
563
- referencedCells.value = extractCellReferences(newCellValue.v);
564
- } else {
565
- referencedCells.value = [];
566
- }
567
-
568
- // Don't close the mini editor - keep it open so user can see the updated references
569
- // closeMiniEditor();
570
- } catch (error) {
571
- alert(`Failed to save cell: ${formatErrorMessage(error, "Unknown error")}`);
572
- }
573
- }
574
-
575
- function handleTableClick(event: MouseEvent) {
576
- // External http(s) links inside cells (XLSX hyperlink fields) —
577
- // open in a new tab instead of navigating the SPA away (#1221).
578
- if (handleExternalLinkClick(event)) return;
579
- const cell = event.target;
580
- // HTMLTableCellElement covers <th> too, and only data cells carry coordinates.
581
- if (!(cell instanceof HTMLTableCellElement) || cell.tagName !== "TD") return;
582
- const row = cell.parentElement;
583
- if (!(row instanceof HTMLTableRowElement)) return;
584
-
585
- const colIndex = cell.cellIndex;
586
- const { rowIndex } = row;
587
-
588
- // Check if the main editor details is open
589
- const isEditorOpen = editorDetails.value?.open ?? false;
590
-
591
- // If editor is closed, open mini editor
592
- if (!isEditorOpen) {
593
- openMiniEditor(rowIndex, colIndex);
594
- return;
595
- }
596
-
597
- // If editor is open, try to find and select this cell in the editor.
598
- if (!editorTextarea.value) return;
599
- try {
600
- const sheets = JSON.parse(editableData.value);
601
- const currentSheet = sheets[activeSheetIndex.value];
602
- if (!currentSheet || !currentSheet.data) return;
603
- const normalizedData = normalizeSheetData(currentSheet.data);
604
- if (!normalizedData[rowIndex] || normalizedData[rowIndex][colIndex] === undefined) {
605
- return;
606
- }
607
- const cellStr = JSON.stringify(normalizedData[rowIndex][colIndex]);
608
- const cellStart = findCellJsonPosition(editableData.value, currentSheet.name, rowIndex, colIndex);
609
- if (cellStart < 0) return;
610
- editorTextarea.value.focus();
611
- editorTextarea.value.setSelectionRange(cellStart, cellStart + cellStr.length);
612
- // Scroll the textarea to make the selection visible.
613
- const textBeforeSelection = editableData.value.substring(0, cellStart);
614
- const lineNumber = textBeforeSelection.split("\n").length;
615
- const lineHeight = 22;
616
- const textarea = editorTextarea.value;
617
- textarea.scrollTop = Math.max(0, lineNumber * lineHeight - textarea.clientHeight / 2);
618
- } catch (error) {
619
- console.error("Failed to select cell in editor:", error);
620
- }
621
- }
622
-
623
- async function applyChanges() {
624
- try {
625
- // Parse the edited JSON
626
- const parsedSheets = JSON.parse(editableData.value);
627
-
628
- // Validate it's an array
629
- if (!Array.isArray(parsedSheets)) {
630
- throw new Error(t("pluginSpreadsheet.dataMustBeArray"));
631
- }
632
-
633
- // Persist to disk (if file-backed) and emit update
634
- await persistSheets(parsedSheets);
635
-
636
- // Reset to first sheet after update
637
- activeSheetIndex.value = 0;
638
- } catch (error) {
639
- alert(t("pluginSpreadsheet.invalidJsonAlert", { error: formatErrorMessage(error, t("pluginSpreadsheet.unknownError")) }));
640
- }
641
- }
642
-
643
- // Watch for external changes to selectedResult
644
- watch(
645
- () => props.selectedResult.data?.sheets,
646
- () => {
647
- fetchSheets().then(() => {
648
- editableData.value = JSON.stringify(resolvedSheets.value || [], null, 2);
649
- // Reset to first sheet when result changes
650
- activeSheetIndex.value = 0;
651
- });
652
- },
653
- );
654
-
655
- // Reset active sheet if it's out of bounds
656
- watch(
657
- () => resolvedSheets.value?.length,
658
- (length) => {
659
- if (length && activeSheetIndex.value >= length) {
660
- activeSheetIndex.value = 0;
661
- }
662
- },
663
- );
664
-
665
- // Highlight selected cell and referenced cells when mini editor is
666
- // open. The per-step DOM work lives in cellHighlights.ts so this
667
- // callback stays trivial and the complexity lands on the helpers,
668
- // each of which is linear.
669
- watch(
670
- [miniEditorOpen, miniEditorCell, referencedCells, renderedHtml],
671
- () => {
672
- clearCellHighlights(tableContainer.value);
673
- if (!miniEditorOpen.value) return;
674
- applyCellHighlights(tableContainer.value, miniEditorCell.value, referencedCells.value);
675
- },
676
- { flush: "post" },
677
- );
678
-
679
- function isEditableTarget(target: EventTarget | null): boolean {
680
- if (!(target instanceof HTMLElement)) return false;
681
- return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable;
682
- }
683
-
684
- function handleKeyboardNavigation(event: KeyboardEvent) {
685
- if (!miniEditorOpen.value || !miniEditorCell.value) return;
686
- if (isEditableTarget(event.target)) return;
687
-
688
- const { row, col } = miniEditorCell.value;
689
- const next = getArrowKeyOffset(event.key, row, col);
690
- if (!next) return;
691
-
692
- try {
693
- const sheets = JSON.parse(editableData.value);
694
- if (!isWithinSheetBounds(sheets[activeSheetIndex.value], next.row, next.col)) return;
695
- event.preventDefault();
696
- openMiniEditor(next.row, next.col);
697
- } catch (error) {
698
- console.error("Failed to navigate cells:", error);
699
- }
700
- }
701
-
702
- // Add keyboard event listener on mount
703
- onMounted(() => {
704
- document.addEventListener("keydown", handleKeyboardNavigation);
705
- });
706
-
707
- // Remove keyboard event listener on unmount
708
- onUnmounted(() => {
709
- document.removeEventListener("keydown", handleKeyboardNavigation);
710
- });
711
- </script>
712
-
713
- <style scoped>
714
- .spreadsheet-container {
715
- width: 100%;
716
- height: 100%;
717
- display: flex;
718
- flex-direction: column;
719
- background: white;
720
- }
721
-
722
- .spreadsheet-content-wrapper {
723
- flex: 1;
724
- overflow-y: auto;
725
- min-height: 0;
726
- }
727
-
728
- .header {
729
- display: flex;
730
- align-items: center;
731
- justify-content: space-between;
732
- margin-bottom: 1em;
733
- }
734
-
735
- .title {
736
- font-size: 2em;
737
- margin: 0;
738
- font-weight: bold;
739
- }
740
-
741
- /* Sheet tabs */
742
- .sheet-tabs {
743
- display: flex;
744
- gap: 0.25em;
745
- margin-bottom: 1em;
746
- border-bottom: 2px solid #e0e0e0;
747
- }
748
-
749
- .sheet-tab {
750
- padding: 0.5em 1em;
751
- background: #f5f5f5;
752
- border: none;
753
- border-top-left-radius: 4px;
754
- border-top-right-radius: 4px;
755
- cursor: pointer;
756
- font-size: 0.9em;
757
- color: #666;
758
- transition: background-color 0.2s;
759
- }
760
-
761
- .sheet-tab:hover {
762
- background: #e8e8e8;
763
- }
764
-
765
- .sheet-tab.active {
766
- background: white;
767
- color: #333;
768
- font-weight: 500;
769
- border-bottom: 2px solid white;
770
- margin-bottom: -2px;
771
- }
772
-
773
- /* Table container */
774
- .table-container {
775
- overflow-x: auto;
776
- background: white;
777
- }
778
-
779
- /* Style the generated table */
780
- .table-container :deep(table) {
781
- border-collapse: collapse;
782
- width: 100%;
783
- font-family: "Segoe UI", Arial, sans-serif;
784
- font-size: 0.9em;
785
- }
786
-
787
- .table-container :deep(td),
788
- .table-container :deep(th) {
789
- border: 1px solid #d0d0d0;
790
- padding: 0.5em 0.75em;
791
- text-align: left;
792
- }
793
-
794
- .table-container :deep(th) {
795
- background-color: #f5f5f5;
796
- font-weight: 600;
797
- color: #333;
798
- }
799
-
800
- .table-container :deep(tr:nth-child(even)) {
801
- background-color: #fafafa;
802
- }
803
-
804
- .table-container :deep(tr:hover) {
805
- background-color: #f0f0f0;
806
- }
807
-
808
- .table-container :deep(.cell-editing) {
809
- background-color: #e8f5e9 !important;
810
- outline: 2px solid #217346 !important;
811
- outline-offset: -2px;
812
- }
813
-
814
- .table-container :deep(.cell-referenced) {
815
- background-color: #fff3e0 !important;
816
- outline: 2px solid #ff9800 !important;
817
- outline-offset: -2px;
818
- }
819
-
820
- /* Error message */
821
- .error {
822
- padding: 1em;
823
- background: #ffebee;
824
- color: #c62828;
825
- border-radius: 4px;
826
- margin: 1em 0;
827
- }
828
-
829
- /* Editor section */
830
- .spreadsheet-source {
831
- padding: 0.5rem;
832
- background: #f5f5f5;
833
- border-top: 1px solid #e0e0e0;
834
- font-family: Consolas, "MS Gothic", "BIZ UDGothic", monospace;
835
- font-size: 0.85rem;
836
- flex-shrink: 0;
837
- }
838
-
839
- .spreadsheet-source summary {
840
- cursor: pointer;
841
- user-select: none;
842
- padding: 0.5rem;
843
- background: #e8e8e8;
844
- border-radius: 4px;
845
- font-weight: 500;
846
- color: #333;
847
- }
848
-
849
- .spreadsheet-source[open] summary {
850
- margin-bottom: 0.5rem;
851
- }
852
-
853
- .spreadsheet-source summary:hover {
854
- background: #d8d8d8;
855
- }
856
-
857
- .spreadsheet-editor {
858
- width: 100%;
859
- height: 40vh;
860
- padding: 1rem;
861
- background: #ffffff;
862
- border: 1px solid #ccc;
863
- border-radius: 4px;
864
- color: #333;
865
- font-family: "Courier New", "MS Gothic", "BIZ UDGothic", monospace;
866
- font-size: 0.9rem;
867
- resize: vertical;
868
- margin-bottom: 0.5rem;
869
- line-height: 1.5;
870
- }
871
-
872
- .spreadsheet-editor:focus {
873
- outline: none;
874
- border-color: #217346;
875
- box-shadow: 0 0 0 2px rgba(33, 115, 70, 0.1);
876
- }
877
-
878
- .apply-btn {
879
- padding: 0.5rem 1rem;
880
- background: #217346;
881
- color: white;
882
- border: none;
883
- border-radius: 4px;
884
- cursor: pointer;
885
- font-size: 0.9rem;
886
- transition: background 0.2s;
887
- font-weight: 500;
888
- }
889
-
890
- .apply-btn:hover {
891
- background: #1e6a3f;
892
- }
893
-
894
- .apply-btn:active {
895
- background: #1a5c36;
896
- }
897
-
898
- .apply-btn:disabled {
899
- background: #cccccc;
900
- color: #666666;
901
- cursor: not-allowed;
902
- opacity: 0.6;
903
- }
904
-
905
- .apply-btn:disabled:hover {
906
- background: #cccccc;
907
- }
908
-
909
- /* Mini Editor Panel */
910
- .mini-editor-panel {
911
- background: #f8f8f8;
912
- border-top: 1px solid #d0d0d0;
913
- flex-shrink: 0;
914
- }
915
-
916
- .mini-editor-content {
917
- display: flex;
918
- align-items: center;
919
- gap: 0.5rem;
920
- padding: 0.5rem 1rem;
921
- }
922
-
923
- .cell-ref {
924
- font-family: Consolas, "MS Gothic", "BIZ UDGothic", monospace;
925
- font-weight: 600;
926
- color: #217346;
927
- font-size: 0.85rem;
928
- min-width: 2rem;
929
- }
930
-
931
- .radio-group {
932
- display: flex;
933
- gap: 0.75rem;
934
- border-right: 1px solid #d0d0d0;
935
- padding-right: 0.75rem;
936
- }
937
-
938
- .radio-option {
939
- display: flex;
940
- align-items: center;
941
- gap: 0.25rem;
942
- cursor: pointer;
943
- font-size: 0.85rem;
944
- color: #555;
945
- }
946
-
947
- .radio-option input[type="radio"] {
948
- cursor: pointer;
949
- width: 0.9rem;
950
- height: 0.9rem;
951
- }
952
-
953
- .form-input {
954
- flex: 1;
955
- padding: 0.4rem 0.6rem;
956
- border: 1px solid #ccc;
957
- border-radius: 3px;
958
- font-size: 0.85rem;
959
- font-family: inherit;
960
- transition: border-color 0.2s;
961
- min-width: 120px;
962
- }
963
-
964
- .form-input:focus {
965
- outline: none;
966
- border-color: #217346;
967
- box-shadow: 0 0 0 2px rgba(33, 115, 70, 0.1);
968
- }
969
-
970
- .form-input::placeholder {
971
- color: #999;
972
- font-size: 0.8rem;
973
- }
974
-
975
- .save-btn,
976
- .cancel-btn {
977
- padding: 0.4rem 0.8rem;
978
- border: none;
979
- border-radius: 3px;
980
- cursor: pointer;
981
- font-size: 0.85rem;
982
- font-weight: 500;
983
- transition: background 0.2s;
984
- }
985
-
986
- .save-btn {
987
- background: #217346;
988
- color: white;
989
- }
990
-
991
- .save-btn:hover {
992
- background: #1e6a3f;
993
- }
994
-
995
- .cancel-btn {
996
- background: transparent;
997
- color: #666;
998
- padding: 0.4rem;
999
- font-size: 1.2rem;
1000
- line-height: 1;
1001
- }
1002
-
1003
- .cancel-btn:hover {
1004
- color: #333;
1005
- background: #e0e0e0;
1006
- }
1007
- </style>