From 0d04d6512c0632e8e86e51add45cc6335751e49e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 07:50:43 +0000 Subject: [PATCH 01/20] feat: migrate to createRoomStore + rc.1 APIs (persistSliceConfigs, SidebarButtons, ThemeSwitch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1/2: rc.2 entirely blocked — @sqlrooms/codemirror@rc.2 and @sqlrooms/pivot@rc.2 published with unresolved pnpm workspace:* references, making npm install fail silently. Staying at rc.1. Documented in CLAUDE.md with blocking notice and rc.1 available APIs. Phase 3: Migrated store from create() to createRoomStore(). Now exports { roomStore, useNotebookStore } — roomStore (StoreApi) passed to , useNotebookStore is the React hook. Renamed api → store in all slice factory calls. Phase 4: Added RoomShell.SidebarButtons in sidebar (auto-generates panel toggles like Sources). Fixed isSelected={false} on stateless buttons (DB Engine, Documentation). Phase 5: Wrapped store in persistSliceConfigs with LayoutConfig schema — mosaic layout state (open panels, split ratios) now persisted to localStorage key sqljob-layout-state-v1. Phase 6b: Added RoomShell.LoadingProgress + RoomShell.CommandPalette (Ctrl+K) inside RoomShell. Replaced manual Moon/Sun theme toggle with ThemeSwitch from @sqlrooms/ui. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- CLAUDE.md | 11 ++++++++--- src/app/room.tsx | 32 ++++++++++++++++++-------------- src/app/store/notebookStore.ts | 24 +++++++++++++++--------- 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7146b8f0..63a0220c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,17 +31,22 @@ ## Stack technique ### Framework UI — sqlrooms -- **`@sqlrooms/room-shell`** : layout mosaic (panneaux redimensionnables), sidebar avec boutons toggle par panneau, `RoomShell` / `RoomPanel` / `RoomShell.Sidebar` / `RoomShell.LayoutComposer`. -- **`@sqlrooms/ui`** : composants Shadcn/Radix (Button, Input, Tooltip, useToast…). +- **`@sqlrooms/room-shell`** : layout mosaic (panneaux redimensionnables), sidebar, `RoomShell` / `RoomPanel` / `RoomShell.Sidebar` / `RoomShell.SidebarButtons` / `RoomShell.LayoutComposer` / `RoomShell.LoadingProgress` / `RoomShell.CommandPalette`. +- **`@sqlrooms/ui`** : composants Shadcn/Radix (Button, Input, Tooltip, useToast, ThemeSwitch…). - **`@sqlrooms/dropzone`** : `FileDropzone` — drag & drop de fichiers. - **`@sqlrooms/sql-editor`** : éditeur SQL CodeMirror. - **`@sqlrooms/utils`** : utilitaires (`convertToValidColumnOrTableName`…). - Version fixée : `0.29.0-rc.1`. +- ⚠️ **Mise à jour rc.2 bloquée** (mai 2026) : `@sqlrooms/codemirror@0.29.0-rc.2` et `@sqlrooms/pivot@0.29.0-rc.2` ont été publiés avec des références `workspace:*` non résolues, rendant toute la chaîne de dépendances inutilisable hors monorepo. Attendre rc.3 pour l'upgrade. +- **APIs rc.1 disponibles mais pas encore utilisées** : `createRoomStore`, `createPersistHelpers`, `persistSliceConfigs`, `RoomShell.SidebarButtons`, `RoomShell.LoadingProgress`, `RoomShell.CommandPalette`, `ThemeSwitch`. ### State management — Zustand - Store principal : `src/app/store/notebookStore.ts` -- Le store fusionne via un proxy `this → get/set` les 9 mixins Alpine migrés : `pagesMixin`, `helpersMixin`, `groupsMixin`, `cellsMixin`, `filesMixin`, `executionMixin`, `parametersMixin`, `editorsMixin`, `exportImportMixin`. +- Store créé via `createRoomStore()` de `@sqlrooms/room-shell` — retourne `{ roomStore, useNotebookStore }`. +- `roomStore` est le store brut passé à `` ; `useNotebookStore` est le hook React. +- Le store fusionne les slices sqlrooms (roomShell, sqlEditor, cells, notebook, canvas) et les 9 slices Zustand purs (pages, helpers, parameters, export, groups, cells, files, execution, copyPaste). - La slice RoomShell (`createRoomShellSlice`) gère le layout mosaic et l'état des panneaux. +- Le layout est persisté via `persistSliceConfigs` (clé localStorage `sqljob-layout-state-v1`). ### DuckDB - **Instance unique** : `src/lib/DuckDBManager.ts` — singleton statique partagé par toutes les cells et le dropzone. diff --git a/src/app/room.tsx b/src/app/room.tsx index b9851252..a7e3839a 100644 --- a/src/app/room.tsx +++ b/src/app/room.tsx @@ -2,16 +2,18 @@ /** * Room — Composant racine utilisant RoomShell de @sqlrooms/room-shell. * - * - RoomShell.Sidebar : boutons de navigation (SQL Editor, Theme, DB Engine, DevMode) + * - RoomShell.Sidebar : sidebar avec boutons auto-générés (SidebarButtons) + boutons custom * - RoomShell.LayoutComposer : mosaic layout (NotebookPanel + DataSourcesPanel) + * - RoomShell.LoadingProgress : barre de progression DuckDB + * - RoomShell.CommandPalette : palette de commandes (Ctrl+K) * - Modals globaux (portals → document.body, indépendants du layout) */ import { RoomShell } from '@sqlrooms/room-shell' -import { useDisclosure, useTheme } from '@sqlrooms/ui' +import { useDisclosure, ThemeSwitch } from '@sqlrooms/ui' import { useShallow } from 'zustand/react/shallow' -import { useNotebookStore } from './store/notebookStore' +import { roomStore, useNotebookStore } from './store/notebookStore' import { SqlEditorModal } from '@sqlrooms/sql-editor' -import { BookHeartIcon, MessageSquareCodeIcon, MoonIcon, PaintbrushIcon, Settings2Icon, SunIcon, TerminalIcon } from 'lucide-react' +import { BookHeartIcon, MessageSquareCodeIcon, PaintbrushIcon, Settings2Icon, TerminalIcon } from 'lucide-react' import { ThemeCustomModal } from './components/modals/ThemeCustomModal' import { ErudaModal } from './components/modals/ErudaModal' import { ConfirmModal } from './components/modals/ConfirmModal' @@ -33,7 +35,6 @@ function SidebarControls() { dbEngine: s.dbEngine, showLayout: s.showLayout, }))) - const { theme, setTheme } = useTheme() const set = useNotebookStore.setState const sqlEditorDisclosure = useDisclosure() const themeModalDisclosure = useDisclosure() @@ -43,6 +44,9 @@ function SidebarControls() { return ( <> + {/* Boutons auto-générés pour les panneaux layout (ex: Sources) */} + + {devMode && ( <> {/* SQL Editor */} @@ -66,6 +70,7 @@ function SidebarControls() { set({ showDbEngineModal: true })} + isSelected={false} icon={DbEngineIcon} /> @@ -80,23 +85,17 @@ function SidebarControls() { )} - {/* Toujours visibles — ancrés en bas via spacer */} + {/* Ancrés en bas via spacer */}
{/* Documentation (gist) */} window.open('https://ihatexcel.github.io/sqljob/?gist=68cd597ba5da05ceba24fb975c05384f', '_blank')} + isSelected={false} icon={BookHeartIcon} /> - {/* Theme toggle */} - setTheme(theme === 'dark' ? 'light' : 'dark')} - icon={theme === 'dark' ? SunIcon : MoonIcon} - /> - {/* DevMode toggle */} + {/* Bascule thème clair/sombre via ThemeSwitch sqlrooms */} + + - + + + {/* Modals globaux — portals vers document.body, indépendants du layout */} diff --git a/src/app/store/notebookStore.ts b/src/app/store/notebookStore.ts index 603b80f1..108fb6c2 100644 --- a/src/app/store/notebookStore.ts +++ b/src/app/store/notebookStore.ts @@ -6,9 +6,8 @@ * forceUpdate() déclenche un re-render React après des mutations profondes. * createRoomShellSlice ajoute le système de layout mosaic (RoomShell). */ -import { create } from 'zustand' import { setAutoFreeze } from 'immer' -import { createRoomShellSlice } from '@sqlrooms/room-shell' +import { createRoomShellSlice, createRoomStore, persistSliceConfigs, LayoutConfig } from '@sqlrooms/room-shell' import { createBaseDuckDbConnector } from '@sqlrooms/duckdb-core' import { createSqlEditorSlice, createDefaultSqlEditorConfig } from '@sqlrooms/sql-editor' import { createCellsSlice as createSqlroomsCellsSlice, createDefaultCellRegistry } from '@sqlrooms/cells' @@ -271,9 +270,15 @@ const duckdbManagerConnector = createBaseDuckDbConnector( ) // ─── Store Zustand ──────────────────────────────────────────────────────────── -export const useNotebookStore = create((set, get, api) => { +export const { roomStore, useRoomStore: useNotebookStore } = createRoomStore( + persistSliceConfigs( + { + name: 'sqljob-layout-state-v1', + sliceConfigSchemas: { layout: LayoutConfig }, + }, + (set, get, store) => { // === Slice SqlEditor === - const sqlEditorState = createSqlEditorSlice({ config: createDefaultSqlEditorConfig() })(set, get, api) + const sqlEditorState = createSqlEditorSlice({ config: createDefaultSqlEditorConfig() })(set, get, store) // === Slice RoomShell : layout mosaic + panels === const roomShellState = createRoomShellSlice({ @@ -299,12 +304,12 @@ export const useNotebookStore = create((set, get, api) => { }, }, }, - })(set, get, api) + })(set, get, store) // === Slices sqlrooms notebook (requis par SheetsTabBar + Notebook de @sqlrooms/cells / @sqlrooms/notebook) === - const sqlroomsCellsState = createSqlroomsCellsSlice({ cellRegistry: createDefaultCellRegistry() })(set, get, api) - const notebookState = createNotebookSlice()(set, get, api) - const canvasState = createCanvasSlice()(set, get, api) + const sqlroomsCellsState = createSqlroomsCellsSlice({ cellRegistry: createDefaultCellRegistry() })(set, get, store) + const notebookState = createNotebookSlice()(set, get, store) + const canvasState = createCanvasSlice()(set, get, store) const initialState = buildInitialState() @@ -440,4 +445,5 @@ export const useNotebookStore = create((set, get, api) => { return ap?.linkGroups || [] }, } -}) + }) +) From 9e3332a7545eb93e78d6ea60349652021cec7a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 09:43:16 +0000 Subject: [PATCH 02/20] fix: remove duplicate Sources button in sidebar RoomShell.Sidebar already renders RoomShellSidebarButtons internally. Explicitly calling in SidebarControls caused the Sources (DatabaseIcon) button to appear twice. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- src/app/room.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/room.tsx b/src/app/room.tsx index a7e3839a..488fff0e 100644 --- a/src/app/room.tsx +++ b/src/app/room.tsx @@ -2,7 +2,7 @@ /** * Room — Composant racine utilisant RoomShell de @sqlrooms/room-shell. * - * - RoomShell.Sidebar : sidebar avec boutons auto-générés (SidebarButtons) + boutons custom + * - RoomShell.Sidebar : sidebar (boutons panels auto-inclus) + boutons custom * - RoomShell.LayoutComposer : mosaic layout (NotebookPanel + DataSourcesPanel) * - RoomShell.LoadingProgress : barre de progression DuckDB * - RoomShell.CommandPalette : palette de commandes (Ctrl+K) @@ -44,9 +44,6 @@ function SidebarControls() { return ( <> - {/* Boutons auto-générés pour les panneaux layout (ex: Sources) */} - - {devMode && ( <> {/* SQL Editor */} From 1a84011927d1558460d3b757cc6ad674ed61c22a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 07:08:52 +0000 Subject: [PATCH 03/20] feat: add pivot cell type using @sqlrooms/pivot@0.29.0-rc.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the workspace:* dependency issue by using npm overrides to pin all transitive workspace:* references to their rc.1 equivalents, allowing @sqlrooms/pivot@rc.2 to install alongside the rc.1 ecosystem. Pivot cell implementation: - Schema (cellTypeSchemas.ts): 'pivot' type with executePivotCell handler, initProps (_pivotReady, _pivotTableName, _pivotColumns), SQL source query - Execution (executionSlice.ts): creates a DuckDB VIEW pivot_src_ from the source SQL, inspects columns via DESCRIBE, stores metadata in cell state - Rendering (CellBody.tsx): PivotBody component renders PivotEditor from @sqlrooms/pivot with querySource={{ tableRef, columns }}; PivotEditor provides drag-and-drop field config + multi-renderer (Table, Chart, TSV); PivotResults uses useSql() which bridges through duckdbManagerConnector to the same DuckDB instance as cell execution npm overrides added for: @sqlrooms/cells, @sqlrooms/ui, @sqlrooms/utils → $direct dep (rc.1) @sqlrooms/duckdb, @sqlrooms/room-store, @sqlrooms/vega → 0.29.0-rc.1 https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- CLAUDE.md | 6 ++-- package-lock.json | 36 ++++++++++++++++++++++ package.json | 9 ++++++ src/app/components/CellBody.tsx | 42 ++++++++++++++++++++++++++ src/app/store/slices/executionSlice.ts | 35 +++++++++++++++++++++ src/lib/cellTypeSchemas.ts | 20 ++++++++++++ 6 files changed, 145 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 63a0220c..3884ca0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,9 +36,9 @@ - **`@sqlrooms/dropzone`** : `FileDropzone` — drag & drop de fichiers. - **`@sqlrooms/sql-editor`** : éditeur SQL CodeMirror. - **`@sqlrooms/utils`** : utilitaires (`convertToValidColumnOrTableName`…). -- Version fixée : `0.29.0-rc.1`. -- ⚠️ **Mise à jour rc.2 bloquée** (mai 2026) : `@sqlrooms/codemirror@0.29.0-rc.2` et `@sqlrooms/pivot@0.29.0-rc.2` ont été publiés avec des références `workspace:*` non résolues, rendant toute la chaîne de dépendances inutilisable hors monorepo. Attendre rc.3 pour l'upgrade. -- **APIs rc.1 disponibles mais pas encore utilisées** : `createRoomStore`, `createPersistHelpers`, `persistSliceConfigs`, `RoomShell.SidebarButtons`, `RoomShell.LoadingProgress`, `RoomShell.CommandPalette`, `ThemeSwitch`. +- Version fixée : `0.29.0-rc.1` (sauf `@sqlrooms/pivot@0.29.0-rc.2` — voir ci-dessous). +- **`@sqlrooms/pivot`** : `0.29.0-rc.2` installé avec npm `overrides` pour contourner les références `workspace:*` non résolues (les dépendances transitives sont forcées sur rc.1). Fournit `PivotEditor` (drag-and-drop, multi-renderer) + `PivotResults` (useSql). L'exécution crée une VIEW DuckDB `pivot_src_` et `PivotResults` calcule les requêtes pivot directement via `useSql` (bridgé sur `DuckDBManager`). +- ⚠️ **rc.2 (packages hors pivot) toujours bloqué** : `@sqlrooms/codemirror@0.29.0-rc.2` et les autres packages rc.2 restent inutilisables hors monorepo. Seul `@sqlrooms/pivot` a pu être intégré via overrides. ### State management — Zustand - Store principal : `src/app/store/notebookStore.ts` diff --git a/package-lock.json b/package-lock.json index 466103b3..0dd952e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@sqlrooms/data-table": "0.29.0-rc.1", "@sqlrooms/dropzone": "0.29.0-rc.1", "@sqlrooms/notebook": "^0.29.0-rc.1", + "@sqlrooms/pivot": "0.29.0-rc.2", "@sqlrooms/room-shell": "0.29.0-rc.1", "@sqlrooms/sql-editor": "0.29.0-rc.1", "@sqlrooms/ui": "0.29.0-rc.1", @@ -4019,6 +4020,41 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@sqlrooms/pivot": { + "version": "0.29.0-rc.2", + "resolved": "https://registry.npmjs.org/@sqlrooms/pivot/-/pivot-0.29.0-rc.2.tgz", + "integrity": "sha512-Xk81S9J0/zUZRI3Gpm4n8gDuRLItoeoEu1oiyWBp54i5cYW7iSVmT/uqqE2pKwhaYSr0fpbtyz6yxPwbZ8aI3w==", + "license": "MIT", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@paralleldrive/cuid2": "^3.0.0", + "@sqlrooms/cells": "workspace:*", + "@sqlrooms/duckdb": "workspace:*", + "@sqlrooms/room-store": "workspace:*", + "@sqlrooms/ui": "workspace:*", + "@sqlrooms/utils": "workspace:*", + "@sqlrooms/vega": "workspace:*", + "immer": "^11.0.1", + "lucide-react": "^0.556.0", + "zod": "^4.1.8", + "zustand": "^5.0.8" + }, + "peerDependencies": { + "apache-arrow": "17.0.0", + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@sqlrooms/pivot/node_modules/lucide-react": { + "version": "0.556.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.556.0.tgz", + "integrity": "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@sqlrooms/recharts": { "version": "0.29.0-rc.1", "resolved": "https://registry.npmjs.org/@sqlrooms/recharts/-/recharts-0.29.0-rc.1.tgz", diff --git a/package.json b/package.json index e93cc33f..f14d30ee 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@sqlrooms/room-shell": "0.29.0-rc.1", "@sqlrooms/sql-editor": "0.29.0-rc.1", "@sqlrooms/ui": "0.29.0-rc.1", + "@sqlrooms/pivot": "0.29.0-rc.2", "@sqlrooms/utils": "0.29.0-rc.1", "@univerjs/preset-sheets-core": "^0.19.0", "@univerjs/presets": "^0.19.0", @@ -61,5 +62,13 @@ "simple-datatables": "^10.2.0", "xlsx": "^0.18.5", "zustand": "^5.0.11" + }, + "overrides": { + "@sqlrooms/cells": "$@sqlrooms/cells", + "@sqlrooms/ui": "$@sqlrooms/ui", + "@sqlrooms/utils": "$@sqlrooms/utils", + "@sqlrooms/duckdb": "0.29.0-rc.1", + "@sqlrooms/room-store": "0.29.0-rc.1", + "@sqlrooms/vega": "0.29.0-rc.1" } } diff --git a/src/app/components/CellBody.tsx b/src/app/components/CellBody.tsx index 99d96008..9c5e6118 100644 --- a/src/app/components/CellBody.tsx +++ b/src/app/components/CellBody.tsx @@ -19,6 +19,7 @@ import { import { DuckDBManager } from '../../lib/DuckDBManager' import DataTablePaginated from '@sqlrooms/data-table/dist/DataTablePaginated' import { SqlBlockEditor } from './sqlblock/SqlBlockEditor' +import { PivotEditor } from '@sqlrooms/pivot' import './UniverSheetElement' // ─── Skeleton ───────────────────────────────────────────────────────────────── @@ -1191,6 +1192,46 @@ function UniverSheetBody({ cell, path, cellIndex }: any) { ) } +// ─── PivotBody ──────────────────────────────────────────────────────────────── +function PivotBody({ cell, path, cellIndex }: any) { + const { devMode, showSqlEditorVisible, _rev } = useNotebookStore(useShallow((s: any) => ({ + devMode: s.devMode, + showSqlEditorVisible: s.showSqlEditorVisible, + _rev: s._rev, + }))) + + const querySource = useMemo(() => { + if (!cell._pivotReady || !cell._pivotTableName || !cell._pivotColumns?.length) return null + return { tableRef: `"${cell._pivotTableName}"`, columns: cell._pivotColumns } + }, [cell._pivotReady, cell._pivotTableName, cell._pivotColumns, _rev]) + + return ( +
+ {devMode && showSqlEditorVisible?.(cell) && ( + + )} + {querySource ? ( +
+ +
+ ) : ( + !cell._status && ( +
+ Exécutez la cellule pour charger les données du pivot. +
+ ) + )} + +
+ ) +} + // ─── CellBody principal ─────────────────────────────────────────────────────── export function CellBody({ cell, path, cellIndex, group }: { cell: any, path: number[], cellIndex: number, group: any }) { const { @@ -1223,6 +1264,7 @@ export function CellBody({ cell, path, cellIndex, group }: { cell: any, path: nu case 'iframe': return case 'sqlStat': return + case 'pivot': return case 'uiParameter': return case 'publipostageWord': diff --git a/src/app/store/slices/executionSlice.ts b/src/app/store/slices/executionSlice.ts index 0533d06b..fb653e1d 100644 --- a/src/app/store/slices/executionSlice.ts +++ b/src/app/store/slices/executionSlice.ts @@ -798,6 +798,41 @@ export const createExecutionSlice = (set: any, get: any) => ({ } }, + async executePivotCell(cell) { + const sourceQuery = ConfigManager.getCellQuery(cell, 'main')?.trim() + if (!sourceQuery) return + + get().setStatus('Préparation du pivot...', 'loading') + + try { + const finalQuery = get().parseQueryWithParameters(sourceQuery, { _name: cell.name || '' }) + const viewName = `pivot_src_${cell._id.replace(/[^a-zA-Z0-9_]/g, '_')}` + + // Créer une vue DuckDB temporaire avec les données source + await DuckDBManager.executeQuery(`CREATE OR REPLACE VIEW "${viewName}" AS (${finalQuery})`) + + // Décrire les colonnes de la vue + const describeResults = await DuckDBManager.executeQuery(`DESCRIBE SELECT * FROM "${viewName}" LIMIT 0`) + const columns = (describeResults || []).map((row) => ({ + name: row.column_name || row.Field || String(Object.values(row)[0]), + type: row.column_type || row.Type || String(Object.values(row)[1]), + })) + + const rowCountResult = await DuckDBManager.executeQuery(`SELECT COUNT(*) AS n FROM "${viewName}"`) + const rowCount = rowCountResult?.[0]?.n ?? 0 + + cell._pivotTableName = viewName + cell._pivotColumns = columns + cell._pivotReady = true + cell._resultInfo = `✅ ${rowCount} ligne(s) — ${columns.length} colonne(s) disponibles` + + set((s: any) => ({ _rev: s._rev + 1 })) + get().setStatus('Pivot prêt', 'success') + } catch (error) { + throw error + } + }, + async executeUiParameterCell(cell) { cell._paramError = null diff --git a/src/lib/cellTypeSchemas.ts b/src/lib/cellTypeSchemas.ts index 7b61c6a4..663dabad 100644 --- a/src/lib/cellTypeSchemas.ts +++ b/src/lib/cellTypeSchemas.ts @@ -135,6 +135,26 @@ export const CELL_TYPE_SCHEMAS = { bodyConfig: { defaultIcon: 'mdi:information-outline', showResultInfoDevOnly: true }, bodyDisplay: { showSkeleton: { excludeWhenSqlEditor: true }, resultInfo: { showDevOnly: true } } }, + pivot: { + executeHandler: 'executePivotCell', + defaultNamePrefix: 'pivot', + exportFields: ['queries', 'preserveUserValue'], + initProps: { _pivotReady: false, _pivotTableName: null, _pivotColumns: null }, + commonParams: ['name', 'queries'], + queryCount: 1, + queryNames: ['main'], + queryLabels: { main: 'Requête SQL (données source)' }, + specificParams: [ + { key: 'queries.main.showQueryEditor', label: "Afficher l'éditeur SQL", inputType: 'checkbox' }, + { key: 'preserveUserValue', label: 'Ne pas ré-exécuter si déjà calculé', inputType: 'checkbox', defaultValue: false } + ], + defaults: { + queries: [{ name: 'main', sql: 'SELECT * FROM source1', engine: 'sql', showQueryEditor: false }], + preserveUserValue: false, + }, + bodyFamily: 'sqlWithPivot', + bodyDisplay: { showSkeleton: { excludeWhenSqlEditor: true } }, + }, uiParameter: { executeHandler: 'executeUiParameterCell', defaultNamePrefix: 'param', From 1258254af23c2226dcc6c6d13959d09c9afb693b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 07:51:22 +0000 Subject: [PATCH 04/20] fix: add pivot cell type to cellTypes list in notebookStore The pivot type was missing from the cellTypes array that populates the add-cell modal, making it invisible to users. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- src/app/store/notebookStore.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/store/notebookStore.ts b/src/app/store/notebookStore.ts index 108fb6c2..2a165a3d 100644 --- a/src/app/store/notebookStore.ts +++ b/src/app/store/notebookStore.ts @@ -234,6 +234,7 @@ function buildInitialState() { { type: 'iframe', label: 'HTML/Iframe', icon: 'web' }, { type: 'sqlStat', label: 'Stat SQL', icon: 'monitoring' }, + { type: 'pivot', label: 'Pivot', icon: 'pivot-table-chart' }, { type: 'publipostageWord', label: 'Publipostage Word', icon: 'description' }, { type: 'pdfme', label: 'PDF (pdfme)', icon: 'picture-as-pdf' }, { type: 'perspective', label: 'Perspective Viewer', icon: 'analytics' }, From f83508025fea67bb6bd746dd56228cd07438c160 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 09:07:40 +0000 Subject: [PATCH 05/20] fix(ci): trigger deploy on PR merge, not only on push GitHub intentionally blocks push-triggered workflows when the actor is github-actions[bot] (GITHUB_TOKEN). Adding a pull_request/closed trigger lets the workflow fire when a PR is merged via the GitHub UI (actor is web-flow, which is not blocked). A "Determine target branch" step resolves the correct base branch for both push and PR-merge events. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- .github/workflows/deploy.yml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba449ef0..e102fba5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,22 +3,35 @@ name: Deploy to GitHub Pages on: push: branches: [main, beta, recette, claude/dev] + pull_request: + branches: [main, beta, recette, claude/dev] + types: [closed] workflow_dispatch: permissions: contents: write concurrency: - group: deploy-${{ github.ref_name }} + group: deploy-${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} cancel-in-progress: true jobs: build-and-deploy: runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.merged == true steps: + - name: Determine target branch + id: target + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "branch=${{ github.event.pull_request.base.ref }}" >> $GITHUB_OUTPUT + else + echo "branch=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + - uses: actions/checkout@v4 with: - ref: ${{ github.ref_name }} + ref: ${{ steps.target.outputs.branch }} - uses: actions/setup-node@v4 with: @@ -41,7 +54,7 @@ jobs: # main → racine du site (/) - name: Deploy main to / - if: github.ref_name == 'main' + if: steps.target.outputs.branch == 'main' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -50,7 +63,7 @@ jobs: # beta / recette → sous-dossier /beta/ - name: Deploy beta to /beta/ - if: github.ref_name == 'beta' || github.ref_name == 'recette' + if: steps.target.outputs.branch == 'beta' || steps.target.outputs.branch == 'recette' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -60,7 +73,7 @@ jobs: # claude/dev → sous-dossier /dev/ - name: Deploy claude/dev to /dev/ - if: github.ref == 'refs/heads/claude/dev' + if: steps.target.outputs.branch == 'claude/dev' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} From c37663859e2d96029d98d0f29a73e5393b1f0b9f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 09:14:11 +0000 Subject: [PATCH 06/20] fix(build): stub PivotCellContent to unblock CDN build @sqlrooms/pivot@rc.2 ships PivotCellContent which imports toDataSourceTable / fromDataSourceTable from @sqlrooms/cells. Those symbols only exist in rc.2 of cells (we pin rc.1). Rollup fails even though we never use PivotCellContent. A load-hook stub short-circuits the resolution for that file. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- vite.config.web-component.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vite.config.web-component.ts b/vite.config.web-component.ts index bd9b4af9..bd7e3633 100644 --- a/vite.config.web-component.ts +++ b/vite.config.web-component.ts @@ -11,6 +11,17 @@ export default defineConfig({ // Avec external: [/^monaco-editor/], cet import devient un bare specifier ESM // que le navigateur ne peut pas résoudre. On le supprime par transform (avant la // résolution external). sqljob n'utilise pas JsonMonacoEditor. + // @sqlrooms/pivot@rc.2 ships PivotCellContent which imports toDataSourceTable / + // fromDataSourceTable from @sqlrooms/cells — those symbols only exist in rc.2 of cells + // (not our pinned rc.1). We never use PivotCellContent; stub it so Rollup can resolve. + { + name: 'stub-pivot-cell-content', + load(id) { + if (id.includes('@sqlrooms/pivot') && id.includes('PivotCellContent')) { + return 'export const PivotCellContent = () => null;' + } + }, + }, { name: 'patch-json-monaco-editor', transform(code, id) { From eff400b964450d3e549ab8d0c540bbb10c72a0c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 09:21:02 +0000 Subject: [PATCH 07/20] fix(build): also stub pivotCellRegistryEntry (rc.2 cells dep) @sqlrooms/pivot@rc.2's pivotCellRegistryEntry.js also imports symbols that only exist in @sqlrooms/cells@rc.2 (resolveSheetSchemaName, findSheetIdForCell). Extend the load-hook stub to cover that file as well. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- vite.config.web-component.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/vite.config.web-component.ts b/vite.config.web-component.ts index bd7e3633..c29ddc93 100644 --- a/vite.config.web-component.ts +++ b/vite.config.web-component.ts @@ -11,15 +11,17 @@ export default defineConfig({ // Avec external: [/^monaco-editor/], cet import devient un bare specifier ESM // que le navigateur ne peut pas résoudre. On le supprime par transform (avant la // résolution external). sqljob n'utilise pas JsonMonacoEditor. - // @sqlrooms/pivot@rc.2 ships PivotCellContent which imports toDataSourceTable / - // fromDataSourceTable from @sqlrooms/cells — those symbols only exist in rc.2 of cells - // (not our pinned rc.1). We never use PivotCellContent; stub it so Rollup can resolve. + // @sqlrooms/pivot@rc.2 ships files that import symbols only present in @sqlrooms/cells@rc.2 + // (toDataSourceTable, fromDataSourceTable, resolveSheetSchemaName, findSheetIdForCell…). + // We pin cells@rc.1 and never use PivotCellContent / pivotCellRegistryEntry; stub them. { - name: 'stub-pivot-cell-content', + name: 'stub-pivot-cells-rc2-deps', load(id) { - if (id.includes('@sqlrooms/pivot') && id.includes('PivotCellContent')) { + if (!id.includes('@sqlrooms/pivot')) return + if (id.includes('PivotCellContent')) return 'export const PivotCellContent = () => null;' - } + if (id.includes('pivotCellRegistryEntry')) + return 'export const pivotCellRegistryEntry = {};' }, }, { From df47ff6856424a0cf4068b17eaedef881f5536b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 10:01:28 +0000 Subject: [PATCH 08/20] feat(pivot): table selector via PivotEditor.TableSelector, no SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove SQL query from pivot cell — source is now a table selected directly from the PivotEditor.TableSelector dropdown - Pass availableTables from _duckdbTables to PivotEditor so the dropdown is populated - Persist selected table in cell.json.selectedTable; rebuild querySource from _duckdbTables columns on each selection (key remount keeps columns fresh) - Simplify executePivotCell to a lightweight validation no-op https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- src/app/components/CellBody.tsx | 63 +++++++++++++++----------- src/app/store/slices/executionSlice.ts | 39 ++++------------ src/lib/cellTypeSchemas.ts | 16 +++---- 3 files changed, 51 insertions(+), 67 deletions(-) diff --git a/src/app/components/CellBody.tsx b/src/app/components/CellBody.tsx index 9c5e6118..3e33ab21 100644 --- a/src/app/components/CellBody.tsx +++ b/src/app/components/CellBody.tsx @@ -1193,41 +1193,52 @@ function UniverSheetBody({ cell, path, cellIndex }: any) { } // ─── PivotBody ──────────────────────────────────────────────────────────────── -function PivotBody({ cell, path, cellIndex }: any) { - const { devMode, showSqlEditorVisible, _rev } = useNotebookStore(useShallow((s: any) => ({ - devMode: s.devMode, - showSqlEditorVisible: s.showSqlEditorVisible, - _rev: s._rev, +function PivotBody({ cell }: any) { + const { _duckdbTables, forceUpdate } = useNotebookStore(useShallow((s: any) => ({ + _duckdbTables: s._duckdbTables, + forceUpdate: s.forceUpdate, }))) + const availableTables = useMemo(() => Object.keys(_duckdbTables || {}), [_duckdbTables]) + + const selectedTable: string = cell.json?.selectedTable || '' + const querySource = useMemo(() => { - if (!cell._pivotReady || !cell._pivotTableName || !cell._pivotColumns?.length) return null - return { tableRef: `"${cell._pivotTableName}"`, columns: cell._pivotColumns } - }, [cell._pivotReady, cell._pivotTableName, cell._pivotColumns, _rev]) + if (!selectedTable || !_duckdbTables?.[selectedTable]) return undefined + const cols = _duckdbTables[selectedTable].columns || [] + return { tableRef: `"${selectedTable}"`, columns: cols } + }, [selectedTable, _duckdbTables]) + + const source = useMemo(() => + selectedTable ? { kind: 'table' as const, tableName: selectedTable } : undefined, + [selectedTable] + ) + + const callbacks = useMemo(() => ({ + setSource: (src: any) => { + if (!cell.json) cell.json = {} + cell.json.selectedTable = src?.kind === 'table' ? src.tableName : '' + forceUpdate() + }, + }), [cell, forceUpdate]) return (
- {devMode && showSqlEditorVisible?.(cell) && ( - - )} - {querySource ? ( -
- + {availableTables.length === 0 ? ( +
+ Aucune table disponible. Chargez d'abord des données.
) : ( - !cell._status && ( -
- Exécutez la cellule pour charger les données du pivot. -
- ) + )} -
) } diff --git a/src/app/store/slices/executionSlice.ts b/src/app/store/slices/executionSlice.ts index fb653e1d..32060bd3 100644 --- a/src/app/store/slices/executionSlice.ts +++ b/src/app/store/slices/executionSlice.ts @@ -799,37 +799,14 @@ export const createExecutionSlice = (set: any, get: any) => ({ }, async executePivotCell(cell) { - const sourceQuery = ConfigManager.getCellQuery(cell, 'main')?.trim() - if (!sourceQuery) return - - get().setStatus('Préparation du pivot...', 'loading') - - try { - const finalQuery = get().parseQueryWithParameters(sourceQuery, { _name: cell.name || '' }) - const viewName = `pivot_src_${cell._id.replace(/[^a-zA-Z0-9_]/g, '_')}` - - // Créer une vue DuckDB temporaire avec les données source - await DuckDBManager.executeQuery(`CREATE OR REPLACE VIEW "${viewName}" AS (${finalQuery})`) - - // Décrire les colonnes de la vue - const describeResults = await DuckDBManager.executeQuery(`DESCRIBE SELECT * FROM "${viewName}" LIMIT 0`) - const columns = (describeResults || []).map((row) => ({ - name: row.column_name || row.Field || String(Object.values(row)[0]), - type: row.column_type || row.Type || String(Object.values(row)[1]), - })) - - const rowCountResult = await DuckDBManager.executeQuery(`SELECT COUNT(*) AS n FROM "${viewName}"`) - const rowCount = rowCountResult?.[0]?.n ?? 0 - - cell._pivotTableName = viewName - cell._pivotColumns = columns - cell._pivotReady = true - cell._resultInfo = `✅ ${rowCount} ligne(s) — ${columns.length} colonne(s) disponibles` - - set((s: any) => ({ _rev: s._rev + 1 })) - get().setStatus('Pivot prêt', 'success') - } catch (error) { - throw error + const selectedTable = cell.json?.selectedTable + if (selectedTable) { + const tables = (get() as any)._duckdbTables || {} + if (tables[selectedTable]) { + cell._resultInfo = `Table: ${selectedTable} — ${tables[selectedTable].columns?.length ?? 0} colonnes` + } else { + throw new Error(`Table "${selectedTable}" introuvable dans DuckDB`) + } } }, diff --git a/src/lib/cellTypeSchemas.ts b/src/lib/cellTypeSchemas.ts index 663dabad..abfbbdc2 100644 --- a/src/lib/cellTypeSchemas.ts +++ b/src/lib/cellTypeSchemas.ts @@ -138,22 +138,18 @@ export const CELL_TYPE_SCHEMAS = { pivot: { executeHandler: 'executePivotCell', defaultNamePrefix: 'pivot', - exportFields: ['queries', 'preserveUserValue'], - initProps: { _pivotReady: false, _pivotTableName: null, _pivotColumns: null }, - commonParams: ['name', 'queries'], - queryCount: 1, - queryNames: ['main'], - queryLabels: { main: 'Requête SQL (données source)' }, + exportFields: ['json', 'preserveUserValue'], + initProps: {}, + commonParams: ['name'], specificParams: [ - { key: 'queries.main.showQueryEditor', label: "Afficher l'éditeur SQL", inputType: 'checkbox' }, { key: 'preserveUserValue', label: 'Ne pas ré-exécuter si déjà calculé', inputType: 'checkbox', defaultValue: false } ], defaults: { - queries: [{ name: 'main', sql: 'SELECT * FROM source1', engine: 'sql', showQueryEditor: false }], + json: { selectedTable: '' }, preserveUserValue: false, }, - bodyFamily: 'sqlWithPivot', - bodyDisplay: { showSkeleton: { excludeWhenSqlEditor: true } }, + bodyFamily: 'pivot', + bodyDisplay: {}, }, uiParameter: { executeHandler: 'executeUiParameterCell', From e8c045b9065f2d01d31f37c010d956873d9cbb25 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 10:12:38 +0000 Subject: [PATCH 09/20] feat(pivot): persist pivot config (renderer, rows, cols, vals) in cell.json callbacks.setConfig fires on every PivotEditor config change; the result is stored in cell.json.pivotConfig and passed back as the config prop on remount, so the layout survives page reload and Gist export/import. Config is cleared when the source table changes. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- src/app/components/CellBody.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/components/CellBody.tsx b/src/app/components/CellBody.tsx index 3e33ab21..842304da 100644 --- a/src/app/components/CellBody.tsx +++ b/src/app/components/CellBody.tsx @@ -1218,6 +1218,12 @@ function PivotBody({ cell }: any) { setSource: (src: any) => { if (!cell.json) cell.json = {} cell.json.selectedTable = src?.kind === 'table' ? src.tableName : '' + cell.json.pivotConfig = undefined // reset config when table changes + forceUpdate() + }, + setConfig: (config: any) => { + if (!cell.json) cell.json = {} + cell.json.pivotConfig = config forceUpdate() }, }), [cell, forceUpdate]) @@ -1233,6 +1239,7 @@ function PivotBody({ cell }: any) { key={selectedTable || '__no_table'} source={source} querySource={querySource} + config={cell.json?.pivotConfig} availableTables={availableTables} callbacks={callbacks} autoRun From ef5d332597a8ed2aa607b25fda0ea3218be5bc09 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 10:41:53 +0000 Subject: [PATCH 10/20] feat(pivot): client mode shows only the pivot result, no config UI In dev mode: full PivotEditor layout (table selector, drag-and-drop fields, renderer selector, output). In client mode: PivotEditor wraps only so the store context is present for DuckDB queries, but none of the configuration panels are rendered. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- src/app/components/CellBody.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/components/CellBody.tsx b/src/app/components/CellBody.tsx index 842304da..fec6b28f 100644 --- a/src/app/components/CellBody.tsx +++ b/src/app/components/CellBody.tsx @@ -1194,9 +1194,10 @@ function UniverSheetBody({ cell, path, cellIndex }: any) { // ─── PivotBody ──────────────────────────────────────────────────────────────── function PivotBody({ cell }: any) { - const { _duckdbTables, forceUpdate } = useNotebookStore(useShallow((s: any) => ({ + const { _duckdbTables, forceUpdate, devMode } = useNotebookStore(useShallow((s: any) => ({ _duckdbTables: s._duckdbTables, forceUpdate: s.forceUpdate, + devMode: s.devMode, }))) const availableTables = useMemo(() => Object.keys(_duckdbTables || {}), [_duckdbTables]) @@ -1228,6 +1229,10 @@ function PivotBody({ cell }: any) { }, }), [cell, forceUpdate]) + if (!selectedTable && !devMode) { + return
Aucune table sélectionnée.
+ } + return (
{availableTables.length === 0 ? ( @@ -1244,7 +1249,9 @@ function PivotBody({ cell }: any) { callbacks={callbacks} autoRun className="h-full" - /> + > + {devMode ? undefined : } + )}
) From 6f6076accde80bbf1e1665e03396405119676951 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:33:43 +0000 Subject: [PATCH 11/20] =?UTF-8?q?fix:=20corrige=20erreur=20DuckDB=20non=20?= =?UTF-8?q?initialis=C3=A9=20et=20double=20popup=20sqlrooms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executeQueryInternal retourne null si DuckDB pas encore prêt, évitant le throw lors de l'auto-appel de refreshTableSchemas par RoomShell au montage - supprime l'appel explicite room.initialize() dans init(), sqlrooms le fait déjà automatiquement au montage de RoomShell https://claude.ai/code/session_01NJyCeZ7b1joKmkwinsMtmK --- src/app/store/notebookStore.ts | 1 + src/app/store/slices/helpersSlice.ts | 7 ------- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/app/store/notebookStore.ts b/src/app/store/notebookStore.ts index 603b80f1..a8f537e6 100644 --- a/src/app/store/notebookStore.ts +++ b/src/app/store/notebookStore.ts @@ -259,6 +259,7 @@ const duckdbManagerConnector = createBaseDuckDbConnector( }, executeQueryInternal: async (sql: string) => { if (DuckDBManager.currentEngine === 'ducklings') return null + if (!DuckDBManager.connInstance) return null try { const result = await DuckDBManager.executeQueryArrow(sql) return result diff --git a/src/app/store/slices/helpersSlice.ts b/src/app/store/slices/helpersSlice.ts index 2a2e1009..d9e14bb5 100644 --- a/src/app/store/slices/helpersSlice.ts +++ b/src/app/store/slices/helpersSlice.ts @@ -108,13 +108,6 @@ export const createHelpersSlice = (set: any, get: any) => ({ } setTimeout(() => setTimeout(() => get().refreshMarkdownCellsForPage(0), 300), 0) await get().refreshDuckdbTables() - if (DuckDBManager.currentEngine !== 'ducklings') { - try { - await get().room.initialize() - } catch (err) { - console.warn('[sqljob] room.initialize() error:', err) - } - } if (DuckDBManager.currentEngine !== 'ducklings') { try { await get().db.refreshTableSchemas() From f9edb2faaf23c46c12b973a470835dc694181553 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:53:09 +0000 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20retourne=20un=20Arrow=20table=20vi?= =?UTF-8?q?de=20quand=20DuckDB=20pas=20encore=20pr=C3=AAt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Évite le crash TypeError 'Cannot read properties of null (reading getChild)' dans refreshTableSchemas au montage de RoomShell : au lieu de null, on retourne un objet minimal compatible Arrow (getChild → null, toArray → []). https://claude.ai/code/session_01NJyCeZ7b1joKmkwinsMtmK --- src/app/store/notebookStore.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/store/notebookStore.ts b/src/app/store/notebookStore.ts index a8f537e6..da1f8920 100644 --- a/src/app/store/notebookStore.ts +++ b/src/app/store/notebookStore.ts @@ -259,7 +259,11 @@ const duckdbManagerConnector = createBaseDuckDbConnector( }, executeQueryInternal: async (sql: string) => { if (DuckDBManager.currentEngine === 'ducklings') return null - if (!DuckDBManager.connInstance) return null + if (!DuckDBManager.connInstance) { + // DuckDB pas encore prêt : retourne un Arrow table vide pour que + // refreshTableSchemas() ne plante pas sur .getChild() + return { schema: { fields: [] }, numRows: 0, getChild: () => null, toArray: () => [] } + } try { const result = await DuckDBManager.executeQueryArrow(sql) return result From e1cb324ad1aab3d9994efb0ec8a321e346263edf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 14:06:30 +0000 Subject: [PATCH 13/20] =?UTF-8?q?chore:=20supprime=20mixins=20legacy=20+?= =?UTF-8?q?=20mise=20=C3=A0=20jour=20DuckDB=20WASM=201.5.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Supprime helpersMixin.ts, parametersMixin.ts, exportImportMixin.ts (logique complètement migrée dans les slices Zustand correspondants) - Met à jour DuckDB WASM de 1.33.1-dev18.0 vers 1.5.2 (version stable) https://claude.ai/code/session_01NJyCeZ7b1joKmkwinsMtmK --- src/app/mixins/exportImportMixin.ts | 489 ---------------------------- src/app/mixins/helpersMixin.ts | 350 -------------------- src/app/mixins/parametersMixin.ts | 361 -------------------- src/lib/DuckDBManager.ts | 3 +- 4 files changed, 1 insertion(+), 1202 deletions(-) delete mode 100644 src/app/mixins/exportImportMixin.ts delete mode 100644 src/app/mixins/helpersMixin.ts delete mode 100644 src/app/mixins/parametersMixin.ts diff --git a/src/app/mixins/exportImportMixin.ts b/src/app/mixins/exportImportMixin.ts deleted file mode 100644 index 8a291671..00000000 --- a/src/app/mixins/exportImportMixin.ts +++ /dev/null @@ -1,489 +0,0 @@ -// @ts-nocheck - -export function exportImportMixin() { - return { - setTheme(themeName) { - const theme = themeName === 'dark' ? 'dark' : 'light' - this.currentTheme = theme; - document.documentElement.classList.remove('light', 'dark'); - document.documentElement.classList.add(theme); - localStorage.setItem('sqljob-theme', theme); - }, - - // ───────────────────────────────────────────────────────────────── - // Export unifié - // ───────────────────────────────────────────────────────────────── - - openExportModal(type) { - // Pour le gist, vérifier d'abord si un token existe - if (type === 'gist' && !GitHubGistManager.hasAccessToken()) { - this.showGistTokenModal = true; - return; - } - - // Valeur par défaut du nom de fichier (avec date/heure pour tous les types) - const now = new Date(); - const yyyymmdd = now.toISOString().slice(0, 10).replace(/-/g, ''); - const hhmmss = now.toTimeString().slice(0, 8).replace(/:/g, ''); - const defaultFileName = `sqljob_${yyyymmdd}_${hhmmss}`; - - // Réinitialiser la modale avec les valeurs par défaut - this.exportModal = { - show: true, - type: type, - fileName: defaultFileName, - description: 'sqljob Notebook Configuration', - devMode: false, - showLayout: false, - includeFiles: false, - encryptGist: false, - gistPassphrase: '' - }; - }, - - async copyExportJson() { - const em = this.exportModal; - try { - const config = await ConfigManager.buildConfigFromState( - this.pages, - em.devMode, - em.showLayout, - !!em.includeFiles, - this.currentTheme, - this.dbEngine, - this.directedAcyclicGraph - ); - const json = exportConfigToJson(config); - // Fallback textarea pour éviter la perte du contexte de geste utilisateur - const ta = document.createElement('textarea'); - ta.value = json; - ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - return true; - } catch (error) { - console.error('Erreur copie JSON:', error); - this.setStatus('Erreur: ' + error.message, 'error'); - return false; - } - }, - - async executeExport() { - const type = this.exportModal.type; - const fileName = this.exportModal.fileName || 'notebook-config.json'; - const description = this.exportModal.description || 'sqljob Notebook Configuration'; - const devMode = this.exportModal.devMode; - const showLayout = this.exportModal.showLayout; - const includeFiles = !!this.exportModal.includeFiles; - - this.exportModal = { ...this.exportModal, show: false }; - - try { - this.isLoading = true; - - // Générer la configuration avec les paramètres choisis - const includeFileData = includeFiles; - const config = await ConfigManager.buildConfigFromState( - this.pages, - devMode, - showLayout, - includeFileData, - this.currentTheme, - this.dbEngine, - this.directedAcyclicGraph - ); - - switch (type) { - case 'gist': - this.setStatus('Création du gist GitHub...', 'loading'); - let passphrase = null; - if (this.exportModal.encryptGist) { - passphrase = (this.exportModal.gistPassphrase || '').trim(); - if (!passphrase) passphrase = GistEncrypt.generatePassphrase(); - } - const gistUrl = await GitHubGistManager.createGist(config, description, fileName, passphrase); - this.gistShareUrl = GitHubGistManager.generateSqljobUrl(gistUrl); - this.gistWasEncrypted = !!passphrase; - this.gistPassphraseToShare = passphrase || ''; - this.showGistModal = true; - this.setStatus('Gist créé avec succès', 'success'); - break; - - case 'json': - this.setStatus('Export JSON...', 'loading'); - let jsonContent; - const jsonPassphrase = this.exportModal.encryptGist ? ((this.exportModal.gistPassphrase || '').trim() || GistEncrypt.generatePassphrase()) : null; - if (jsonPassphrase) { - const jsonString = JSON.stringify(config); - const encrypted = await GistEncrypt.encrypt(jsonString, jsonPassphrase); - jsonContent = JSON.stringify(encrypted, null, 2); - } else { - jsonContent = exportConfigToJson(config); - } - const jsonBlob = new Blob([jsonContent], { type: 'application/json' }); - const jsonFileName = fileName.endsWith('.json') ? fileName : fileName + '.json'; - FileHandler.downloadFile(jsonBlob, jsonFileName); - this.setStatus('Configuration exportée', 'success'); - break; - - case 'base64': - this.setStatus('Export Base64...', 'loading'); - const jsonStr = JSON.stringify(config); - const base64String = ConfigManager.encodeUTF8ToBase64(jsonStr); - const base64Blob = new Blob([base64String], { type: 'text/plain' }); - const base64FileName = fileName.endsWith('.txt') ? fileName : fileName + '.txt'; - FileHandler.downloadFile(base64Blob, base64FileName); - this.setStatus('Configuration exportée en Base64', 'success'); - break; - - case 'html': - this.setStatus('Génération HTML...', 'loading'); - const htmlFileName = (fileName.endsWith('.html') ? fileName : fileName + '.html'); - const htmlPassphrase = this.exportModal.encryptGist ? ((this.exportModal.gistPassphrase || '').trim() || GistEncrypt.generatePassphrase()) : null; - await this.exportHTMLWithConfig(config, htmlFileName, htmlPassphrase, includeFiles); - this.setStatus('HTML exporté', 'success'); - break; - } - } catch (error) { - console.error('Erreur export:', error); - this.setStatus('Erreur: ' + error.message, 'error'); - - // Si erreur d'authentification pour gist - if (type === 'gist' && (error.message.includes('authentifié') || error.message.includes('Unauthorized'))) { - GitHubGistManager.clearAccessToken(); - this.showGistTokenModal = true; - } - } finally { - this.isLoading = false; - } - }, - - async exportHTMLWithConfig(config, fileName = 'index.sqljob.html', passphrase = null, includeFiles = false) { - const sourceFilesPayload = []; - const docxTemplatesPayload = []; - - // Collecter les fichiers embarqués sous forme de chaînes HTML - let embeddedScripts = ''; - - const collectFilesForTemplate = async (group, groupPath = []) => { - for (let ci = 0; ci < (group.cells || []).length; ci++) { - const cell = group.cells[ci]; - - if (cell.type === 'source' && cell._currentFile && cell._fileName) { - const safeName = cell.name.replace(/[^a-zA-Z0-9_]/g, '_'); - const ab = await cell._currentFile.arrayBuffer(); - const compressed = await FileHandler.compressGzip(ab); - const b64 = FileHandler.arrayBufferToBase64(compressed); - if (passphrase) { - sourceFilesPayload.push({ id: `sourceFile_${safeName}`, sourceName: cell.name, fileName: cell._fileName, base64: b64 }); - } else { - embeddedScripts += ` \n`; - } - } - - if (cell.type === 'publipostageWord' && cell.docxTemplateBase64 && cell.docxTemplateFileName) { - const cellPath = [...groupPath, ci].join('_'); - const stableId = `docxTemplate_${cellPath}`; - const docxBytes = FileHandler.base64ToUint8Array(cell.docxTemplateBase64); - const docxCompressed = await FileHandler.compressGzip(docxBytes.buffer || docxBytes); - const docxB64 = FileHandler.arrayBufferToBase64(docxCompressed); - if (passphrase) { - docxTemplatesPayload.push({ id: stableId, cellPath, fileName: cell.docxTemplateFileName, base64: docxB64, compressed: true }); - } else { - embeddedScripts += ` \n`; - } - } - } - for (let ci = 0; ci < (group.children || []).length; ci++) { - await collectFilesForTemplate(group.children[ci], [...groupPath, ci]); - } - }; - - if (includeFiles) { - for (let pi = 0; pi < this.pages.length; pi++) { - for (let gi = 0; gi < this.pages[pi].groups.length; gi++) { - await collectFilesForTemplate(this.pages[pi].groups[gi], [gi]); - } - for (let gi = 0; gi < (this.pages[pi].linkGroups || []).length; gi++) { - await collectFilesForTemplate(this.pages[pi].linkGroups[gi], [-1, gi]); - } - } - } - - // Construire la balise de config - let configScriptTag; - if (passphrase) { - const payload = { config, sourceFiles: sourceFilesPayload, docxTemplates: docxTemplatesPayload }; - let payloadStr; - try { payloadStr = JSON.stringify(payload); } catch (e) { payloadStr = '[stringify error]'; } - const encrypted = await GistEncrypt.encrypt(payloadStr, passphrase); - const configScriptContent = btoa(JSON.stringify(encrypted)); - configScriptTag = ` \n`; - } else { - const configBase64 = ConfigManager.encodeUTF8ToBase64(exportConfigToJson(config)); - configScriptTag = ` \n`; - } - - // URLs GitHub Pages — MIME type correct, pas de trace CDN externe - const sqljobSrc = 'https://ihatexcel.github.io/sqljob/dist-cdn/sqljob.js'; - const sqljobCss = 'https://ihatexcel.github.io/sqljob/dist-cdn/sqljob.css'; - - // Template HTML fixe — identique à test-cdn.html - const htmlContent = ` - - - - - sqljob - -${configScriptTag}${embeddedScripts} - - - - -`; - - const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' }); - FileHandler.downloadFile(blob, fileName); - }, - - cancelExport() { - this.exportModal = { ...this.exportModal, show: false }; - }, - - saveGithubToken() { - if (!this.githubToken || this.githubToken.trim() === '') { - this.setStatus('Veuillez saisir un token', 'error'); - return; - } - - try { - GitHubGistManager.setAccessToken(this.githubToken.trim()); - this.showGistTokenModal = false; - this.githubToken = ''; - this.setStatus('Token GitHub enregistré', 'success'); - - // Afficher la modale d'export pour le gist - setTimeout(() => this.openExportModal('gist'), 300); - } catch (error) { - this.setStatus('Erreur: ' + error.message, 'error'); - } - }, - - cancelGithubToken() { - this.showGistTokenModal = false; - this.githubToken = ''; - }, - - copyGistUrl() { - navigator.clipboard.writeText(this.gistShareUrl).then(() => { - this.setStatus('URL copiée dans le presse-papiers', 'success'); - }).catch(() => { - this.setStatus('Erreur lors de la copie', 'error'); - }); - }, - - copyGistPassphrase() { - navigator.clipboard.writeText(this.gistPassphraseToShare).then(() => { - this.setStatus('Mot de passe copié dans le presse-papiers', 'success'); - }).catch(() => { - this.setStatus('Erreur lors de la copie', 'error'); - }); - }, - - closeGistModal() { - this.showGistModal = false; - this.gistShareUrl = ''; - this.gistWasEncrypted = false; - this.gistPassphraseToShare = ''; - }, - - openGistUrl() { - if (this.gistShareUrl) { - window.open(this.gistShareUrl, '_blank'); - } - }, - - // ───────────────────────────────────────────────────────────────── - - async loadConfig(event) { - const file = event.target.files[0]; - if (!file) return; - - try { - const text = await file.text(); - const parsed = JSON.parse(text); - - event.target.value = ''; - - if (GistEncrypt.isEncrypted(parsed)) { - this._pendingEncryptedJson = parsed; - this.showJsonPassphraseModal = true; - this.jsonPassphrase = ''; - this.jsonPassphraseError = ''; - return; - } - - await this.applyImportedConfig(parsed); - } catch (error) { - this.setStatus('Erreur import: ' + error.message, 'error'); - } - }, - - cancelJsonPassphraseModal() { - this.showJsonPassphraseModal = false; - this._pendingEncryptedJson = null; - this.jsonPassphrase = ''; - this.jsonPassphraseError = ''; - }, - - async unlockJsonConfig() { - const pass = (this.jsonPassphrase || '').trim(); - if (!pass) { this.jsonPassphraseError = 'Veuillez entrer la mot de passe'; return; } - this.jsonPassphraseError = ''; - this.jsonPassphraseLoading = true; - try { - const decrypted = await GistEncrypt.decrypt(this._pendingEncryptedJson, pass); - const config = JSON.parse(decrypted); - await ConfigManager.prepareConfigForLoad(config); - this._pendingEncryptedJson = null; - this.showJsonPassphraseModal = false; - this.jsonPassphrase = ''; - await this.applyImportedConfig(config); - this.setStatus('Configuration chargée', 'success'); - } catch (e) { - this.jsonPassphraseError = e.message || 'Mot de passe incorrecte'; - } finally { - this.jsonPassphraseLoading = false; - } - }, - - async applyImportedConfig(config) { - await ConfigManager.prepareConfigForLoad(config); - const initCell = (cell, cellIndex) => initializeCell(cell, cellIndex, { generateId: () => this.generateCellId() }); - - // Helper récursif pour initialiser un groupe et ses enfants - const initGroup = (group, groupIndex) => { - const newGroup = { - _id: group.id || this.generateGroupId(), - _type: group.type || 'core', - direction: group.direction || 'row', - style: group.style || '', - _order: ConfigManager.normalizeOrder(group.order, groupIndex), - cells: (group.cells || []).map((cell, cellIndex) => initCell(ConfigManager.normalizeCell({ ...cell }), cellIndex)), - accordion: group.accordion || false, - title: group.title || '', - accordionOpen: group.accordionOpen !== false // true par défaut - }; - - // Ajouter tabsChild et name - newGroup.tabsChild = group.tabsChild || false; - newGroup.name = group.name || ''; - - if (Array.isArray(group.queries) && group.queries.length > 0) { - newGroup.queries = group.queries.map((q, i) => ({ - name: q.name || 'main', - sql: q.query || q.sql || '', - engine: q.engine || 'sql', - showQueryEditor: (q.showQueryEditor ?? q.clientVisible) === true - })); - } else { - newGroup.queries = []; - } - - if (group.loop) { - newGroup.loop = { - enabled: group.loop.enabled || false, - query: group.loop.query || '', - zip: group.loop.zip || false, - zipQuery: group.loop.zipQuery || '' - }; - } else { - newGroup.loop = { enabled: false, query: '', zip: false, zipQuery: '' }; - } - - if (group.children && group.children.length > 0) { - newGroup.children = group.children.map((child, childIndex) => { - const initializedChild = initGroup(child, childIndex); - initializedChild._order = ConfigManager.normalizeOrder(child.order, childIndex); - return initializedChild; - }); - } - - return newGroup; - }; - - // Charger les pages depuis la config - let loadedPages = (config.job?.pages || []).map((page, pageIndex) => { - const allGroups = (page.groups || []).map((group, groupIndex) => initGroup(group, groupIndex)); - const initGroups = allGroups.filter(g => g._type === 'core'); - const initLinkGroups = allGroups.filter(g => g._type === 'link'); - - return { - _id: page.id || this.generatePageId(), - name: page.name || `Feuille ${pageIndex + 1}`, - groups: initGroups, - linkGroups: initLinkGroups - }; - }); - - // Si aucune page n'existe, créer une page par défaut - if (loadedPages.length === 0) { - loadedPages = [{ - _id: this.generatePageId(), - name: 'Feuille 1', - groups: [], - linkGroups: [] - }]; - } - - this.pages = loadedPages; - this.activePageIndex = 0; - this._pagesInitialized.clear(); - this.ensureAllCellsHaveNames(); - - // Charger les fichiers source en attente (depuis la config JSON) - await this.loadPendingSourceFiles(); - - // Évaluer les ifQuery des groupes - await this.evaluateAllGroupIfQueries(); - - // Auto-exécution au chargement du notebook (page 0) - await this.runAllGroups(); - if (this.pages[0]) this._pagesInitialized.add(this.pages[0]._id); - this.$nextTick(() => setTimeout(() => this.refreshMarkdownCellsForPage(0), 300)); - - // Mettre à jour le moteur DB si différent dans la config importée - const configDbEngine = config.ui?.dbEngine; - if (configDbEngine && configDbEngine !== this.dbEngine) { - await this.switchDbEngine(configDbEngine); - } - - // Mettre à jour le DAG si présent dans la config importée - if (config.ui?.directedAcyclicGraph !== undefined) { - this.directedAcyclicGraph = config.ui.directedAcyclicGraph === true; - } - - // Mettre à jour devMode si présent dans la config importée - if (config.ui?.devMode !== undefined) { - this.devMode = config.ui.devMode !== false; - } - - // Mettre à jour showLayout si présent (rétrocompat: displaySettings) - if (config.ui?.showLayout !== undefined || config.ui?.displaySettings !== undefined) { - this.showLayout = (config.ui?.showLayout ?? config.ui?.displaySettings) !== false; - } - - // Mettre à jour le thème si présent dans la config importée - const configTheme = config.ui?.theme; - if (configTheme && this.availableThemes.includes(configTheme)) { - this.setTheme(configTheme); - } - - this.setStatus('Configuration chargée', 'success'); - }, - }; -} diff --git a/src/app/mixins/helpersMixin.ts b/src/app/mixins/helpersMixin.ts deleted file mode 100644 index 1ce6cd3e..00000000 --- a/src/app/mixins/helpersMixin.ts +++ /dev/null @@ -1,350 +0,0 @@ -// @ts-nocheck -import { DuckDBManager } from '../../lib/DuckDBManager'; -import { safeEvalJs } from '../../lib/safeEval'; - -export function helpersMixin() { - return { - hasSourceCells() { - // Vérifie récursivement si le notebook contient des cellules source (fichiers) - const checkGroups = (groups) => { - for (const group of groups) { - if (group.cells?.some(cell => cell.type === 'source')) return true; - if (group.children && checkGroups(group.children)) return true; - } - return false; - }; - return this.pages.some(page => - checkGroups(page.groups || []) || checkGroups(page.linkGroups || []) - ); - }, - - canUseDucklings() { - // Ducklings ne supporte pas les fichiers - return !this.hasSourceCells(); - }, - - async switchDbEngine(newEngine) { - if (newEngine === this.dbEngine) return; - - // Validation pour Ducklings - if (newEngine === 'ducklings' && !this.canUseDucklings()) { - this.setStatus('Ducklings ne supporte pas les notebooks avec fichiers. Supprimez les cellules source pour utiliser Ducklings.', 'error'); - return; - } - - this.isLoading = true; - try { - await DuckDBManager.switchEngine(newEngine, (msg, type) => this.setStatus(msg, type)); - this.dbEngine = newEngine; - localStorage.setItem('sqljob-dbEngine', newEngine); - this.setStatus(`Moteur changé vers ${newEngine === 'ducklings' ? 'Ducklings' : 'DuckDB WASM'}`, 'success'); - } catch (error) { - this.setStatus('Erreur lors du changement de moteur: ' + error.message, 'error'); - } finally { - this.isLoading = false; - } - }, - - // ───────────────────────────────────────────────────────────────── - // INITIALISATION - // ───────────────────────────────────────────────────────────────── - async refreshDuckdbTables() { - try { - const tableRows = await DuckDBManager.executeQuery(`SHOW TABLES`); - const result: Record = {}; - for (const row of tableRows) { - const name = row.name ?? row.table_name; - if (!name) continue; - try { - const countRows = await DuckDBManager.executeQuery(`SELECT COUNT(*) as cnt FROM "${name}"`); - const descRows = await DuckDBManager.executeQuery(`DESCRIBE "${name}"`); - result[name] = { - rowCount: Number(countRows[0]?.cnt ?? 0), - columns: descRows.map((r: any) => ({ name: r.column_name, type: r.column_type })), - }; - } catch { - result[name] = { rowCount: 0, columns: [] }; - } - } - this._duckdbTables = result; - } catch { - // DuckDB pas encore prêt, on ignore silencieusement - } - }, - - async init() { - try { - await DuckDBManager.initDuckDB((msg, type) => this.setStatus(msg, type)); - this.ensureAllCellsHaveNames(); - // Charger les fichiers embarqués dans le HTML - await this.loadEmbeddedFiles(); - - // Charger les fichiers source en attente (depuis la config Gist avec fileBase64) - await this.loadPendingSourceFiles(); - - // Évaluer les ifQuery des groupes (condition d'affichage en mode client) - await this.evaluateAllGroupIfQueries(); - - // Auto-exécution au chargement du notebook (page 0) - await this.runAllGroups(); - if (this.pages[0]) this._pagesInitialized.add(this.pages[0]._id); - this.$nextTick(() => setTimeout(() => this.refreshMarkdownCellsForPage(0), 300)); - // Rafraîchir le panneau Tables DuckDB après l'exécution initiale - await this.refreshDuckdbTables(); - // Appel room.initialize() comme createRoomStore le fait dans le mosaic example - // → db.initialize() → refreshTableSchemas() → peuple db.schemaTrees pour SqlEditorModal - try { - await this.room.initialize(); - } catch (err) { - console.warn('[sqljob] room.initialize() error:', err); - } - // db.schemaTrees peut rester vide si deepEquals([],[]) a bloqué la mise à jour initiale. - // Un second appel avec des tables créées par runAllGroups() force la synchronisation. - try { - await this.db.refreshTableSchemas(); - console.log('[sqljob] schemaTrees:', this.db.schemaTrees); - } catch (err) { - console.warn('[sqljob] refreshTableSchemas error:', err); - } - // Signaler à RoomShell que l'init est terminée - this.room = { ...this.room, initialized: true }; - } catch (error) { - this.setStatus('Erreur d\'initialisation: ' + error.message, 'error'); - } finally { - // Remettre la page 1 ouverte une fois tout chargé - this.activePageIndex = 0; - } - }, - - async evaluateGroupIfQuery(group) { - const q = ConfigManager.getGroupIfQuery(group); - if (!group || !q) { - - return true; - } - - const sql = q.sql; - const langType = q.engine || 'sql'; - - try { - if (langType === 'js') { - const jsCode = this.parseQueryWithParameters(sql); - const result = safeEvalJs(jsCode); - const finalResult = result === true || (result !== null && result !== false && result !== undefined); - return finalResult; - } else { - const finalQuery = this.parseQueryWithParameters(sql); - const results = await DuckDBManager.executeQuery(finalQuery); - - if (!results || results.length === 0) { - return false; - } - - const firstVal = Object.values(results[0])[0]; - const finalResult = firstVal === true || (firstVal !== null && firstVal !== false && firstVal !== undefined); - return finalResult; - } - } catch (err) { - console.error(' ❌ [evaluateGroupIfQuery] Erreur:', err); - return false; - } - }, - - // Évaluer toutes les requêtes conditionnelles des groupes (récursif) et mettre à jour _ifQueryResult - async evaluateAllGroupIfQueries() { - const evaluateRecursive = async (groups, path = []) => { - for (let gi = 0; gi < (groups || []).length; gi++) { - const group = groups[gi]; - const currentPath = [...path, gi]; - const ifQuery = ConfigManager.getGroupIfQuery(group); - if (ifQuery) { - group._ifQueryResult = await this.evaluateGroupIfQuery(group); - } else { - group._ifQueryResult = true; - } - if (group.children?.length) { - await evaluateRecursive(group.children, currentPath); - } - } - }; - for (const page of this.pages || []) { - await evaluateRecursive(page.groups || []); - await evaluateRecursive(page.linkGroups || []); - } - }, - - // ───────────────────────────────────────────────────────────────── - // HELPERS - // ───────────────────────────────────────────────────────────────── - setStatus(message, type) { - // En clientMode, ne pas afficher les confirmations de succès (mais effacer le loading) - if (!this.devMode && type === 'success') { - this.status = ''; - this.statusType = ''; - return; - } - this.status = message; - this.statusType = type; - if (type !== 'loading') { - setTimeout(() => { this.status = ''; this.statusType = ''; }, 1000); - } - }, - - - // Synchronise le contenu de la modal vers l'éditeur EasyMDE. - // Ne rien faire quand engine sql/js : EasyMDE affiche _markdownContent (résultat d'exécution), - // la modale édite queries.main.sql (la requête source) — ils ne doivent pas être liés. - syncMarkdownToEditor(path, cellIndex) { - const cell = this.getCellAtPath(path, cellIndex); - if (!cell || !cell._easyMDE) return; - const engine = ConfigManager.getCellEngine(cell, 'main'); - if (engine === 'sql' || engine === 'js') return; - const currentValue = cell._easyMDE.value(); - const targetContent = ConfigManager.getCellEditableContent(cell); - if (currentValue !== targetContent) { - cell._easyMDE.value(targetContent); - } - }, - - getCellIcon(type) { - const found = this.cellTypes.find(ct => ct.type === type); - return found ? found.icon : ''; - }, - - generateCellId() { - return 'cell_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); - }, - - generateGroupId() { - return 'group_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); - }, - - generatePageId() { - return 'page_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); - }, - - // Vérifier si un nom de source/param existe déjà dans toutes les pages - isNameUniqueAcrossPages(name, type, excludePageIndex = null, excludePath = null, excludeCellIndex = null) { - if (!name || !name.trim()) return true; - - const trimmedName = name.trim(); - - // Types qui doivent être uniques globalement - if (type !== 'source' && type !== 'uiParameter') { - return true; - } - - // Parcourir toutes les pages - for (let pi = 0; pi < this.pages.length; pi++) { - const page = this.pages[pi]; - - // Fonction récursive pour chercher dans les groupes - const checkInGroups = (groups, currentPath = []) => { - for (let gi = 0; gi < groups.length; gi++) { - const group = groups[gi]; - const groupPath = [...currentPath, gi]; - - for (let ci = 0; ci < (group.cells || []).length; ci++) { - const cell = group.cells[ci]; - - // Vérifier si c'est la cellule à exclure - if (excludePageIndex === pi && - excludePath && - JSON.stringify(excludePath) === JSON.stringify(groupPath) && - excludeCellIndex === ci) { - continue; - } - - // Vérifier le type et le nom - if (cell.type === type && cell.name && cell.name.trim() === trimmedName) { - return false; - } - } - - // Vérifier récursivement dans les enfants - if (group.children && group.children.length > 0) { - if (!checkInGroups(group.children, groupPath)) { - return false; - } - } - } - return true; - }; - - // Vérifier dans les groupes de la page - if (!checkInGroups(page.groups)) { - return false; - } - - // Vérifier dans les linkGroups de la page - if (page.linkGroups && !checkInGroups(page.linkGroups)) { - return false; - } - } - - return true; - }, - - // Obtenir tous les noms de source/param existants - getAllNamesOfType(type) { - const names = []; - - if (type !== 'source' && type !== 'uiParameter') { - return names; - } - - for (let pi = 0; pi < this.pages.length; pi++) { - const page = this.pages[pi]; - - const collectFromGroups = (groups) => { - for (const group of groups) { - for (const cell of (group.cells || [])) { - if (cell.type === type && cell.name && cell.name.trim()) { - names.push(cell.name.trim()); - } - } - if (group.children) { - collectFromGroups(group.children); - } - } - }; - - collectFromGroups(page.groups); - if (page.linkGroups) { - collectFromGroups(page.linkGroups); - } - } - - return names; - }, - - // Helper pour accéder à une cellule par ses indices - getCell(groupIndex, cellIndex) { - return this.groups[groupIndex]?.cells[cellIndex]; - }, - - // Télécharger le fichier source d'une cellule - downloadSourceFile(pathOrIndex, cellIndex) { - const path = Array.isArray(pathOrIndex) ? pathOrIndex : [pathOrIndex]; - const cell = this.getCellAtPath(path, cellIndex); - - if (!cell || cell.type !== 'source') { - this.setStatus('Cellule source introuvable', 'error'); - return; - } - - if (!cell._currentFile || !cell._fileName) { - this.setStatus('Aucun fichier à télécharger', 'error'); - return; - } - - try { - // Télécharger le fichier - FileHandler.downloadFile(cell._currentFile, cell._fileName); - this.setStatus('Fichier téléchargé', 'success'); - } catch (error) { - this.setStatus('Erreur lors du téléchargement: ' + error.message, 'error'); - } - }, - }; -} diff --git a/src/app/mixins/parametersMixin.ts b/src/app/mixins/parametersMixin.ts deleted file mode 100644 index 5cf031fc..00000000 --- a/src/app/mixins/parametersMixin.ts +++ /dev/null @@ -1,361 +0,0 @@ -// @ts-nocheck - -export function parametersMixin() { - return { - // Collecter tous les paramètres définis dans les cellules uiParameter - getParameters() { - const params = {}; - const collectFromGroup = (group) => { - for (const cell of (group?.cells || [])) { - const refName = ConfigManager.getCellReferenceName(cell); - if (cell.type === 'uiParameter' && refName) { - params[refName] = cell._value || ''; - } - // Cellule Univer matérialisée → {{ cellName }} résout vers le nom de la table DuckDB - if (cell.type === 'univerSheet' && cell.json?.univerConfig?.materializeAsDuckDB && cell.name) { - params[cell.name] = cell.name; - } - } - for (const child of (group?.children || [])) { - collectFromGroup(child); - } - }; - - for (const group of (this.groups || [])) { - collectFromGroup(group); - } - - // Ajouter la variable {{ _loop }} si elle est définie (pendant l'exécution d'une boucle) - if (this._currentLoopValue !== null && this._currentLoopValue !== undefined) { - params['_loop'] = this._currentLoopValue; - } - - return params; - }, - - // Parser une requête SQL et remplacer les {{ param }} par leurs valeurs - parseQueryWithParameters(query, extraParams = {}) { - if (!query) return query; - - const params = { ...this.getParameters(), ...extraParams }; - let parsedQuery = query; - - // Remplacer tous les {{ paramName }} par leurs valeurs - for (const [paramName, paramValue] of Object.entries(params)) { - // Échapper les caractères spéciaux pour la regex - const escapedName = paramName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp('\\{\\{\\s*' + escapedName + '\\s*\\}\\}', 'g'); - // Échapper les apostrophes dans la valeur pour éviter les injections SQL - const escapedValue = String(paramValue).replace(/'/g, "''"); - parsedQuery = parsedQuery.replace(regex, escapedValue); - } - - return parsedQuery; - }, - - // ───────────────────────────────────────────────────────────────── - // DAG (Directed Acyclic Graph) - Rafraîchissement automatique - // ───────────────────────────────────────────────────────────────── - - // Trouver tous les paramètres référencés dans une query ({{ paramName }}) - findReferencedParams(query) { - if (!query) return []; - const params = []; - const regex = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g; - let match; - while ((match = regex.exec(query)) !== null) { - if (!params.includes(match[1])) { - params.push(match[1]); - } - } - return params; - }, - - // Trouver toutes les cellules qui dépendent d'un paramètre donné - // Retourne un tableau de {cell, path, cellIndex} pour les types DAG-compatibles - findDependentCells(paramName) { - const dependents = []; - const dagTypes = ['uiParameter', 'sql', 'table', 'perspective', 'sqlStat', 'univerSheet', 'source', 'markdown', 'iframe']; - - const searchInGroup = (group, path) => { - for (let cellIndex = 0; cellIndex < (group.cells || []).length; cellIndex++) { - const cell = group.cells[cellIndex]; - if (!dagTypes.includes(cell.type)) continue; - - // Vérifier si la query référence le paramètre - const query = ConfigManager.getCellQuery(cell, 0) || ''; - const referencedParams = this.findReferencedParams(query); - - if (referencedParams.includes(paramName)) { - dependents.push({ cell, path: [...path], cellIndex }); - } - } - // Récursif sur les enfants - if (group.children) { - for (let i = 0; i < group.children.length; i++) { - searchInGroup(group.children[i], [...path, i]); - } - } - }; - - // Chercher dans tous les groupes de la page active - for (let gi = 0; gi < this.groups.length; gi++) { - searchInGroup(this.groups[gi], [gi]); - } - - return dependents; - }, - - // Trouver tous les groupes qui dépendent d'un paramètre donné (via ifQuery) - // Retourne un tableau de {group, path} - findDependentGroups(paramName) { - const dependents = []; - - const searchInGroup = (group, path) => { - // Vérifier si queries[0] du groupe référence le paramètre - const q = ConfigManager.getGroupIfQuery(group); - if (q && q.sql) { - const referencedParams = this.findReferencedParams(q.sql); - if (referencedParams.includes(paramName)) { - dependents.push({ group, path: [...path] }); - } - } - // Récursif sur les enfants - if (group.children) { - for (let i = 0; i < group.children.length; i++) { - searchInGroup(group.children[i], [...path, i]); - } - } - }; - - // Chercher dans tous les groupes de la page active - for (let gi = 0; gi < this.groups.length; gi++) { - searchInGroup(this.groups[gi], [gi]); - } - - return dependents; - }, - - // Détecter les cycles dans le DAG - // Retourne true si un cycle est détecté - detectCycleInDAG() { - // Construire le graphe de dépendances - const graph = new Map(); // paramName -> [paramNames dépendants] - const allParams = new Set(); - - const collectFromGroup = (group) => { - for (const cell of (group.cells || [])) { - const refName = ConfigManager.getCellReferenceName(cell); - if (cell.type === 'uiParameter' && refName) { - allParams.add(refName); - // Trouver les paramètres référencés dans la query de ce uiParameter - const refs = this.findReferencedParams(ConfigManager.getCellQuery(cell, 0) || ''); - if (!graph.has(refName)) { - graph.set(refName, []); - } - // Ce paramètre dépend des paramètres référencés - for (const ref of refs) { - if (!graph.has(ref)) { - graph.set(ref, []); - } - graph.get(ref).push(refName); - } - } - } - for (const child of (group.children || [])) { - collectFromGroup(child); - } - }; - - for (const group of this.groups) { - collectFromGroup(group); - } - - // Détection de cycle avec DFS - const visited = new Set(); - const recStack = new Set(); - - const hasCycle = (node) => { - if (recStack.has(node)) return true; - if (visited.has(node)) return false; - - visited.add(node); - recStack.add(node); - - for (const neighbor of (graph.get(node) || [])) { - if (hasCycle(neighbor)) return true; - } - - recStack.delete(node); - return false; - }; - - for (const param of allParams) { - if (hasCycle(param)) { - return true; - } - } - - return false; - }, - - // Callback appelé lorsqu'une valeur de paramètre UI est modifiée par l'utilisateur - async onParameterValueChange(cell) { - - // Si le DAG n'est pas activé, ne rien faire - if (!this.directedAcyclicGraph) { - return; - } - - const paramName = ConfigManager.getCellReferenceName(cell); - if (!paramName) { - return; - } - - // Annuler le timer précédent (debounce) - if (this._dagDebounceTimer) { - clearTimeout(this._dagDebounceTimer); - this._dagDebounceTimer = null; - } - - // Démarrer un nouveau timer - this._dagDebounceTimer = setTimeout(async () => { - this._dagDebounceTimer = null; - - try { - await this._executeDAGRefresh(paramName); - } catch (error) { - console.error('❌ [DAG] Erreur lors du rafraîchissement:', error); - this.setStatus('❌ Erreur DAG: ' + error.message, 'error'); - } - }, this._dagDebounceDelay); - }, - - // Exécuter le rafraîchissement DAG (appelé après le debounce) - async _executeDAGRefresh(paramName) { - // Vérifier les cycles avant de procéder - if (this.detectCycleInDAG()) { - console.error('🔴 [DAG] Cycle détecté dans le DAG'); - this.setStatus('⚠️ Cycle détecté dans le DAG - rafraîchissement automatique désactivé', 'error'); - this.directedAcyclicGraph = false; - return; - } - - // Trouver toutes les cellules qui dépendent de ce paramètre - const dependentCells = this.findDependentCells(paramName); - - // Trouver tous les groupes qui dépendent de ce paramètre (via ifQuery) - const dependentGroups = this.findDependentGroups(paramName); - - const totalDependents = dependentCells.length + dependentGroups.length; - - if (totalDependents === 0) { - return; - } - - - // Réévaluer les ifQuery des groupes dépendants - for (let i = 0; i < dependentGroups.length; i++) { - const dep = dependentGroups[i]; - try { - const previousResult = dep.group._ifQueryResult; - const newResult = await this.evaluateGroupIfQuery(dep.group); - dep.group._ifQueryResult = newResult; - } catch (error) { - console.error(` ❌ [DAG] Erreur évaluation groupe ${i + 1}:`, error); - } - } - - // Exécuter les cellules dépendantes dans l'ordre - for (let i = 0; i < dependentCells.length; i++) { - const dep = dependentCells[i]; - const depCell = dep.cell; - - // uiParameter: ne skip que si l'utilisateur a saisi manuellement (_userModified) - // autres types: skip toujours si preserveUserValue est vrai - if (depCell.preserveUserValue && (depCell.type !== 'uiParameter' || depCell._userModified)) { - continue; - } - - try { - await this.runCellAt(dep.path, dep.cellIndex); - } catch (error) { - console.error(` ❌ [DAG] Erreur cellule ${i + 1}:`, error); - } - } - - }, - - // Générer un nom de paramètre unique (param1, param2, param3...) - vérifie dans TOUTES les pages - generateUniqueParamName() { - const existingNames = new Set(); - - const collectNames = (groups) => { - for (const group of groups) { - for (const cell of (group.cells || [])) { - const ref = ConfigManager.getCellReferenceName(cell); - if (cell.type === 'uiParameter' && ref) { - existingNames.add(ref); - } - } - if (group.children) { - collectNames(group.children); - } - } - }; - - // Collecter les noms de toutes les pages - for (const page of this.pages) { - collectNames(page.groups); - if (page.linkGroups) { - collectNames(page.linkGroups); - } - } - - // Trouver le prochain numéro disponible - let num = 1; - while (existingNames.has('param' + num)) { - num++; - } - - return 'param' + num; - }, - - // Vérifie si un nom de paramètre est déjà utilisé (récursif) - vérifie dans TOUTES les pages - isParamNameUsed(paramName, excludeId) { - let used = false; - const checkGroups = (groups) => { - for (const group of groups) { - for (const cell of (group.cells || [])) { - if (cell.type === 'uiParameter' && - cell._id !== excludeId && - ConfigManager.getCellReferenceName(cell) === paramName) { - used = true; - return; - } - } - if (group.children && !used) { - checkGroups(group.children); - } - if (used) return; - } - }; - - // Vérifier dans toutes les pages - for (const page of this.pages) { - checkGroups(page.groups); - if (used) return true; - if (page.linkGroups) { - checkGroups(page.linkGroups); - if (used) return true; - } - } - return used; - }, - - /** Valide le nom d'un uiParameter (alias de validateCellName pour cohérence). */ - validateParamName(pathOrIndex, cellIndex) { - this.validateCellName(pathOrIndex, cellIndex); - }, - }; -} diff --git a/src/lib/DuckDBManager.ts b/src/lib/DuckDBManager.ts index eed42e26..c6469357 100644 --- a/src/lib/DuckDBManager.ts +++ b/src/lib/DuckDBManager.ts @@ -1,4 +1,3 @@ -// @ts-nocheck export class DuckDBManager { static dbInstance = null; @@ -8,7 +7,7 @@ static workerRef = null; // Versions et URLs des CDN (chargés dynamiquement selon le moteur) - static DUCKDB_WASM_VERSION = '1.33.1-dev18.0'; + static DUCKDB_WASM_VERSION = '1.5.2'; static DUCKLINGS_VERSION = '1.4.4'; static getDuckDBWasmUrl() { From 26076b1d42df6567ed62d546a6343928fd815ba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 14:07:07 +0000 Subject: [PATCH 14/20] =?UTF-8?q?docs:=20refonte=20README=20=E2=80=94=20so?= =?UTF-8?q?bre,=20concis,=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprime l'ancien README (Alpine.js, architecture obsolète, emojis). Nouveau README : stack actuelle (React/Zustand/DuckDB 1.5.2/sqlrooms), structure du projet, quick start. https://claude.ai/code/session_01NJyCeZ7b1joKmkwinsMtmK --- README.md | 272 ++++++++++-------------------------------------------- 1 file changed, 47 insertions(+), 225 deletions(-) diff --git a/README.md b/README.md index 9fdbfc0f..f48c3e3c 100644 --- a/README.md +++ b/README.md @@ -1,260 +1,82 @@ -# 🦆 sqlJob Notebook +# sqljob -**Client-side SQL notebook powered by DuckDB-WASM** +Client-side SQL notebook powered by DuckDB-WASM. Load files, write SQL, build charts and reports — entirely in the browser, no server required. -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![DuckDB](https://img.shields.io/badge/DuckDB-WASM-blue)](https://duckdb.org/) -[![AI-Assisted Development](https://img.shields.io/badge/Development-AI--Assisted-purple)](https://cursor.sh) - -> **I Hate Excel, so I built a simple, client-side SQL job runner.** 📊 -> 📥 Load, 🔄 transform, and 📈 visualize your data directly in the browser—no setup, no fuss. 🚀 - ---- - -## ✨ Features - -- **🌐 Zero Setup**: Pure client-side execution—no server, no installation -- **📂 Multi-Source Support**: Load multiple CSV, Parquet, Excel files simultaneously -- **📓 Notebook Interface**: Organize your analysis in executable cells (Markdown, SQL, Charts, Tables) -- **📊 Plotly Visualizations**: Create interactive charts with full Plotly.js support -- **💾 Portable Exports**: Export standalone HTML files with embedded data -- **🔄 Auto-execution**: Configure cells to run automatically after data loads -- **🎨 Dark Mode UI**: Modern, responsive interface -- **📄 PDF Export**: Print-friendly output for reports +**[Try it →](https://ihatexcel.github.io/sqljob)** --- -## ⚠️ Early Development Notice - -**This project started on December 19, 2025 and is far from finalized.** +## What it does -- 🚧 **Not production-ready** - Active development, APIs may change -- 🐛 **Expect bugs** - Testing and stabilization ongoing -- 📝 **Documentation incomplete** - Features being added daily -- 💡 **Feedback welcome** - Open issues/PRs to help shape the project - -**Current status**: Experimental / Proof of concept -**Use at your own risk** - Not recommended for critical workflows yet +- Load CSV, Parquet, and Excel files directly into DuckDB +- Write SQL across multiple notebook pages with grouped, auto-executable cells +- Generate charts using SQL role syntax: `SELECT date::XAXIS, revenue::BARCHART FROM sales` +- Build dynamic reports with parameters, conditional groups, and Univer spreadsheet cells +- Export to standalone HTML, PDF, or share via GitHub Gist (with optional encryption) +- Embed as a web component via CDN: `` --- -## 🚀 Quick Start - -### Option 1: Download & Open -1. Download [`index.html`](https://github.com/ihatexcel/sqljob/releases/latest) -2. Open it in your browser -3. Drag & drop your CSV/Excel file -4. Start querying with SQL! - -### Option 2: Use Online -👉 [Try it now](https://ihatexcel.github.io/sqljob) +## Quick start -### Option 3: Build from Source ```bash git clone https://github.com/ihatexcel/sqljob.git cd sqljob -# Open index.html in your browser - that's it! +npm install +npm run dev ``` ---- - -## 🎯 Use Cases - -| Scenario | Solution | -|----------|----------| -| 📊 **Replace Excel Data Pipelines** | Distribute SQL-based data processing without requiring end-users to install anything | -| 🔄 **Power Query Alternative** | Share transformation logic as portable HTML—no Excel license needed | -| 📤 **Data Products for Non-Technical Users** | Package your SQL workflows into self-service tools colleagues can run in their browser | -| 🎓 **SQL Training Without Setup** | Give students/analysts a zero-install environment to learn data manipulation | -| 📈 **Quick Data QA/Validation** | Drop files, run checks, export results—2 minutes from raw data to insights | - ---- - -## 🏗️ Architecture - -### Cell Types - -| Type | Description | Use Case | -|------|-------------|----------| -| 📝 **Markdown** | Rich text, HTML, SVG | Documentation, headers | -| 📂 **Sources** | File upload zones | Load CSV/Excel/Parquet | -| 🗄️ **SQL** | Execute queries | Data exploration | -| 📊 **Table** | Display results | Preview datasets | -| 📈 **Plot** | Plotly charts | Visualizations | -| 📤 **SQL Export** | Download results | Export transformed data | -| 🖼️ **Iframe** | Render HTML | Custom reports | - -### Technology Stack - -**Core:** -- [DuckDB-WASM](https://github.com/duckdb/duckdb-wasm) - In-browser SQL engine -- [Alpine.js](https://alpinejs.dev/)- Reactive UI framework - -**UI Components:** -- [Tabulator](https://tabulator.info/) - Interactive tables -- [Plotly.js](https://plotly.com/javascript/) - Charting library -- [Marked.js](https://marked.js.org/) - Markdown rendering - -**File Processing:** -- [SheetJS (xlsx)](https://sheetjs.com/) `0.18.5` - Excel parsing ---- - -## 🎨 Inspiration - -This project draws inspiration from: -- [**SQLrooms**](https://sqlrooms.org/) - Production-ready data webapp leveraging DuckDB-WASM -- [**Perspective.js**](https://perspective.finos.org/) - Streaming data visualization (note: Plotly.js used here requires intermediate arrays, not streaming-capable) -- [**Huey**](https://github.com/rpbouman/huey) - Vanilla JS approach to DuckDB-WASM (no framework overhead) -- [**Power Query (Excel)**](https://support.microsoft.com/en-us/office/about-power-query-in-excel-7104fbee-9e62-4cb9-a02e-5bfb1a6c536a) - ETL for the masses - -Special thanks to these projects for pioneering accessible data tools! +Or use it directly online — no installation needed. --- -## 🤖 AI-Assisted Development - -This project contains code generated and refined with: -- [Claude](https://claude.ai) (Anthropic) -- [Cursor](https://cursor.sh) (AI-powered IDE) +## Stack -Human-written architecture, AI-assisted implementation. 🤝 +| Layer | Technology | +|---|---| +| SQL engine | [DuckDB-WASM](https://duckdb.org/docs/api/wasm/overview.html) 1.5.2 | +| UI framework | React 18 + [sqlrooms](https://sqlrooms.org/) | +| State | Zustand 5 | +| Build | Vite 5 + TypeScript | +| Styling | Tailwind CSS 4 | +| Charts | ECharts 5 | +| Spreadsheet | Univer | --- -## 📦 Configuration Format +## Development -sqlJob uses a JSON configuration format for cells: -```json -{ - "job": { - "autoExecuteWithoutSources": false, - "cells": [ - { - "type": "markdown", - "content": "# sqlJob ⚡💻\n## I Hate Excel, so I built a simple, client-side SQL job runner. 🛠️\n📥 Load, 🔄 transform, and 📊 visualize your data directly in the browser—no setup, no fuss. 🚀" - }, - { - "type": "sources", - "autoRunNextCells": true, - "sources": [ - { - "name": "source1", - "importText": "Glissez-déposez votre fichier ici", - "query": "CREATE OR REPLACE TABLE source1 AS SELECT * FROM read_csv_auto('{fileNameUpload}', ALL_VARCHAR=true, HEADER=true)", - "xlsx": { - "options": { - "type": "array", - "raw": false, - "dateNF": "dd/mm/yyyy", - "cellDates": true, - "cellNF": false, - "cellText": false - }, - "toCsvOptions": { - "dateNF": "dd/mm/yyyy", - "FS": ",", - "RS": "\n" - }, - "sheetSelection": { - "type": { - "auto": true, - "index": false, - "name": false - }, - "index": 0, - "name": "" - } - } - } - ] - }, - { - "type": "table", - "query": "SELECT * FROM source1 LIMIT 100", - "maxRows": 1000 - }, - { - "type": "sqlExport", - "query": "COPY (SELECT * from source1) TO '{fileName}' (FORMAT CSV, HEADER, DELIMITER ';')", - "fileNameQuery": "SELECT 'export_' || current_timestamp::text || '.csv' as file_name", - "mimeType": "" - }, - { - "type": "plot", - "query": "// Variables: container, Plotly, + les tables configurées\nconst limitedSource = source1.slice(0, 1000);\nconst x = limitedSource.map(r => Object.values(r)[0]);\nconst y = limitedSource.map(r => Object.values(r)[1]);\n\nPlotly.newPlot(container, [{\n x: x,\n y: y,\n type: \"bar\"\n}], { title: \"Graphique\" });", - "tables": "source1" - } - ] - }, - "ui": { - "devMode": true - } -} +```bash +npm run dev # dev server (localhost:5173) +npm run build # production build → dist/ +npm run dev:cdn # CDN web-component dev (localhost:5174) ``` -### Export Formats - -- **JSON Config**: Portable configuration file -- **Base64 Config**: Embed in file -- **Standalone HTML**: Fully self-contained with embedded data -- **PDF**: Print-ready reports via browser print +The CI pipeline (`deploy.yml`) handles the CDN bundle build and GitHub Pages deployment automatically on push to `main`, `beta`, or `claude/dev`. --- -## 🛠️ Development +## Project structure -### File Structure -``` -sqljob/ -├── index.html # Main notebook interface -├── README.md -├── LICENSE ``` - -### Key Classes -```javascript -ConfigManager // Configuration handling -FileHandler // File I/O & compression -DuckDBManager // Database operations -PlotlyManager // Chart generation +src/ + app/ + components/ # React components (panels, cells, modals) + store/ + notebookStore.ts # Main Zustand store + slices/ # Feature slices (cells, execution, export…) + room.tsx # Root layout (RoomShell) + lib/ + DuckDBManager.ts # DuckDB singleton + ConfigManager.ts # Notebook config (load, save, Gist) + EChartSqlParser.ts# SQL → ECharts config + CDNManager.ts # Dynamic CDN library loader + web-component/ + sqljob-app.ts # custom element ``` -**Requirements:** -- Modern browser with WASM support -- `CompressionStream` API (for exports) - ---- - -## 📄 License - -This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. - ---- - -## 🙏 Acknowledgments - -- **DuckDB Team** - For the incredible WASM build -- **Alpine.js Community** - For the reactive simplicity -- **Plotly Team** - For open-source charting -- **Open Source Community** - For the tools that made this possible - ---- - -## 📬 Contact - -**Théo Nobella-Pichonnier** - -- 💼 [Linkedin](https://fr.linkedin.com/in/th%C3%A9o-nobella-97a9b3157) - --- -## ⭐ Star History - -If this project helped you, consider giving it a star! ⭐ - -[![Star History Chart](https://api.star-history.com/svg?repos=ihatexcel/sqljob&type=Date)](https://star-history.com/#ihatexcel/sqljob&Date) - ---- +## License -**Made with ❤️ by Théo Nobella-Pichonnier** -*"I hate Excel, so I built this."* +MIT — © Théo Nobella-Pichonnier From d03ceef797ef8613ec9a39cf706e41faf46b4762 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 14:17:26 +0000 Subject: [PATCH 15/20] =?UTF-8?q?refactor:=20supprime=20@ts-nocheck=20?= =?UTF-8?q?=E2=80=94=20corrections=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprime les 50 directives @ts-nocheck sur l'ensemble du codebase. Ajoute src/global.d.ts pour les propriétés window custom (XLSX, PizZip, docxtemplater, perspectiveClient…) et éléments HTML personnalisés. Corrige les erreurs de types résiduelles : isSelected manquant sur SidebarButton, casts LocaleType/INumfmtLocaleTag, SqlBlockConfig.sql, FilterItem.cond, fetchDistinctValues signature à 3 paramètres. https://claude.ai/code/session_01NJyCeZ7b1joKmkwinsMtmK --- src/app/App.tsx | 1 - src/app/components/CellBody.tsx | 1 - src/app/components/CellHeader.tsx | 1 - src/app/components/DataSourcesPanel.tsx | 3 +- src/app/components/GroupContainer.tsx | 1 - src/app/components/NotebookLayout.tsx | 1 - src/app/components/NotebookPanel.tsx | 1 - src/app/components/NotebookPanelSqljob.tsx | 1 - src/app/components/NotebookPanelSqlrooms.tsx | 1 - src/app/components/PageContent.tsx | 1 - src/app/components/SqlDataTable.tsx | 1 - src/app/components/SqlEditorWidget.tsx | 1 - src/app/components/UniverSheetElement.ts | 4 +- src/app/components/modals/CellConfigModal.tsx | 1 - src/app/components/modals/DbEngineModal.tsx | 1 - src/app/components/modals/ErudaModal.tsx | 1 - src/app/components/modals/ExportModals.tsx | 3 +- .../components/modals/GistPassphraseModal.tsx | 1 - src/app/components/modals/GroupModals.tsx | 1 - src/app/components/modals/SimpleModals.tsx | 1 - .../components/modals/ThemeCustomModal.tsx | 1 - .../components/sqlblock/ChartConfigEditor.tsx | 1 - .../components/sqlblock/SqlBlockEditor.tsx | 13 ++- src/app/room.tsx | 4 +- src/app/store/notebookStore.ts | 1 - src/app/store/slices/cellsSlice.ts | 1 - src/app/store/slices/copyPasteSlice.ts | 1 - src/app/store/slices/executionSlice.ts | 14 +-- src/app/store/slices/exportSlice.ts | 1 - src/app/store/slices/filesSlice.ts | 5 +- src/app/store/slices/groupsSlice.ts | 1 - src/app/store/slices/helpersSlice.ts | 1 - src/app/store/slices/pagesSlice.ts | 1 - src/app/store/slices/parametersSlice.ts | 1 - src/app/store/uiStores.ts | 1 - src/global.d.ts | 95 +++++++++++++++++++ src/lib/CDNManager.ts | 5 +- src/lib/CellConfigService.ts | 7 +- src/lib/ConfigManager.ts | 21 ++-- src/lib/EChartSqlParser.ts | 1 - src/lib/FileHandler.ts | 4 +- src/lib/GistEncrypt.ts | 1 - src/lib/GitHubGistManager.ts | 1 - src/lib/SqlBlockTypes.ts | 1 + src/lib/cellTypeSchemas.ts | 1 - src/lib/icons.tsx | 1 - src/lib/safeEval.ts | 1 - src/lib/utils.ts | 1 - src/web-component/sqljob-app.ts | 1 - tsconfig.json | 2 +- 50 files changed, 138 insertions(+), 79 deletions(-) create mode 100644 src/global.d.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index a1441879..e6989be8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Composant racine React — remplace mount.ts + htmlTemplates.generateAppHTML() * diff --git a/src/app/components/CellBody.tsx b/src/app/components/CellBody.tsx index 99d96008..14a676f8 100644 --- a/src/app/components/CellBody.tsx +++ b/src/app/components/CellBody.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Rendu du body d'une cellule selon son type. * Remplace les templates Alpine générés par CellBodyRenderer. diff --git a/src/app/components/CellHeader.tsx b/src/app/components/CellHeader.tsx index 10709de1..1317dd77 100644 --- a/src/app/components/CellHeader.tsx +++ b/src/app/components/CellHeader.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useState, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useNotebookStore } from '../store/notebookStore' diff --git a/src/app/components/DataSourcesPanel.tsx b/src/app/components/DataSourcesPanel.tsx index 0a5cf1f8..b8651ff6 100644 --- a/src/app/components/DataSourcesPanel.tsx +++ b/src/app/components/DataSourcesPanel.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * DataSourcesPanel — Panneau latéral de gestion des sources de données. * Rendu dans le mosaic layout de RoomShell (placement: 'sidebar'). @@ -56,7 +55,7 @@ function FilesSection() {
{f.source === 'dropzone' && ( - + )} {f.size > 0 && ( diff --git a/src/app/components/GroupContainer.tsx b/src/app/components/GroupContainer.tsx index 8a37275f..5ae2d5d6 100644 --- a/src/app/components/GroupContainer.tsx +++ b/src/app/components/GroupContainer.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useNotebookStore } from '../store/notebookStore' diff --git a/src/app/components/NotebookLayout.tsx b/src/app/components/NotebookLayout.tsx index 0572a6e2..98634e33 100644 --- a/src/app/components/NotebookLayout.tsx +++ b/src/app/components/NotebookLayout.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useRef, useEffect, useState, useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' import { useNotebookStore } from '../store/notebookStore' diff --git a/src/app/components/NotebookPanel.tsx b/src/app/components/NotebookPanel.tsx index 8867d11d..1b004a3d 100644 --- a/src/app/components/NotebookPanel.tsx +++ b/src/app/components/NotebookPanel.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * NotebookPanel — Wrapper principal du panneau notebook. * Gère le switch entre : diff --git a/src/app/components/NotebookPanelSqljob.tsx b/src/app/components/NotebookPanelSqljob.tsx index 254dc2c5..a82363c4 100644 --- a/src/app/components/NotebookPanelSqljob.tsx +++ b/src/app/components/NotebookPanelSqljob.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * NotebookPanelSqljob — Panneau notebook custom sqljob (cells, DAG, pages). * Renommé depuis NotebookPanel. Contient la logique propre à sqljob : diff --git a/src/app/components/NotebookPanelSqlrooms.tsx b/src/app/components/NotebookPanelSqlrooms.tsx index 455214c4..42b44dae 100644 --- a/src/app/components/NotebookPanelSqlrooms.tsx +++ b/src/app/components/NotebookPanelSqlrooms.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * NotebookPanelSqlrooms — Panneau notebook natif sqlrooms. * Utilise les composants @sqlrooms/cells (SheetsTabBar) et @sqlrooms/notebook (Notebook). diff --git a/src/app/components/PageContent.tsx b/src/app/components/PageContent.tsx index 2d0aaa0d..a579bef6 100644 --- a/src/app/components/PageContent.tsx +++ b/src/app/components/PageContent.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useShallow } from 'zustand/react/shallow' import { useNotebookStore } from '../store/notebookStore' import { GroupContainer } from './GroupContainer' diff --git a/src/app/components/SqlDataTable.tsx b/src/app/components/SqlDataTable.tsx index 28f105e8..f3cafa7f 100644 --- a/src/app/components/SqlDataTable.tsx +++ b/src/app/components/SqlDataTable.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * SqlDataTable — remplace SimpleDatatables pour les cellules sql/table. * Utilise DataTablePaginated de @sqlrooms/data-table avec tri et pagination diff --git a/src/app/components/SqlEditorWidget.tsx b/src/app/components/SqlEditorWidget.tsx index 71ba3936..06e531e1 100644 --- a/src/app/components/SqlEditorWidget.tsx +++ b/src/app/components/SqlEditorWidget.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Éditeur SQL/JS React. * Pour les cellules SQL/DuckDB : utilise SqlMonacoEditor de @sqlrooms/sql-editor diff --git a/src/app/components/UniverSheetElement.ts b/src/app/components/UniverSheetElement.ts index 3c2875aa..4d03ffa1 100644 --- a/src/app/components/UniverSheetElement.ts +++ b/src/app/components/UniverSheetElement.ts @@ -346,7 +346,7 @@ class UniverSheetElement extends LitElement { } const { univer, univerAPI } = createUniver({ - locale: localeEntry.type, + locale: localeEntry.type as any, locales: { [localeEntry.type]: mergeLocales( localeData, @@ -412,7 +412,7 @@ class UniverSheetElement extends LitElement { univerAPI.createWorkbook(workbookData) // Locale pour le formatage des nombres (séparateurs décimaux, format des dates…) - univerAPI.getActiveWorkbook()?.setNumfmtLocal?.(localeStr.replace('-', '_')) + univerAPI.getActiveWorkbook()?.setNumfmtLocal?.(localeStr.replace('-', '_') as any) // Format des colonnes date/timestamp via setNumberFormat — formats localisés if (params.rowColumnFormats?.length && params.rows?.length) { diff --git a/src/app/components/modals/CellConfigModal.tsx b/src/app/components/modals/CellConfigModal.tsx index cc16026d..c6373f18 100644 --- a/src/app/components/modals/CellConfigModal.tsx +++ b/src/app/components/modals/CellConfigModal.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useShallow } from 'zustand/react/shallow' import { useNotebookStore } from '../../store/notebookStore' import { ConfigManager } from '../../../lib/ConfigManager' diff --git a/src/app/components/modals/DbEngineModal.tsx b/src/app/components/modals/DbEngineModal.tsx index ab2cfc16..821d2586 100644 --- a/src/app/components/modals/DbEngineModal.tsx +++ b/src/app/components/modals/DbEngineModal.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useShallow } from 'zustand/react/shallow' import { useNotebookStore } from '../../store/notebookStore' import { diff --git a/src/app/components/modals/ErudaModal.tsx b/src/app/components/modals/ErudaModal.tsx index 190dfb29..f3b8326c 100644 --- a/src/app/components/modals/ErudaModal.tsx +++ b/src/app/components/modals/ErudaModal.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useEffect, useRef, useState } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@sqlrooms/ui' import { MessageSquareCodeIcon } from 'lucide-react' diff --git a/src/app/components/modals/ExportModals.tsx b/src/app/components/modals/ExportModals.tsx index 8ab67c71..f27dc62c 100644 --- a/src/app/components/modals/ExportModals.tsx +++ b/src/app/components/modals/ExportModals.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Modals d'export : ExportModal, GistTokenModal, GistResultModal, JsonPassphraseModal */ @@ -116,7 +115,7 @@ export function ExportModal() { { - const GistEncrypt = window.GistEncrypt + const GistEncrypt = window.GistEncrypt as any update({ encryptGist: v, gistPassphrase: v && !em.gistPassphrase ? GistEncrypt?.generatePassphrase() || '' : em.gistPassphrase diff --git a/src/app/components/modals/GistPassphraseModal.tsx b/src/app/components/modals/GistPassphraseModal.tsx index 5a2ad5bb..899a0de1 100644 --- a/src/app/components/modals/GistPassphraseModal.tsx +++ b/src/app/components/modals/GistPassphraseModal.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useState } from 'react' import { ConfigManager } from '../../../lib/ConfigManager' import { GistEncrypt } from '../../../lib/GistEncrypt' diff --git a/src/app/components/modals/GroupModals.tsx b/src/app/components/modals/GroupModals.tsx index 8b54fb82..e40d0491 100644 --- a/src/app/components/modals/GroupModals.tsx +++ b/src/app/components/modals/GroupModals.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * LoopConfigModal + GroupSettingsModal + ChildGroupModal */ diff --git a/src/app/components/modals/SimpleModals.tsx b/src/app/components/modals/SimpleModals.tsx index 2c0b7711..24c4dcb4 100644 --- a/src/app/components/modals/SimpleModals.tsx +++ b/src/app/components/modals/SimpleModals.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Modals simples : AddGroup, InsertGroup, InsertCell, AddCellToGroup */ diff --git a/src/app/components/modals/ThemeCustomModal.tsx b/src/app/components/modals/ThemeCustomModal.tsx index 0275de63..fb74584b 100644 --- a/src/app/components/modals/ThemeCustomModal.tsx +++ b/src/app/components/modals/ThemeCustomModal.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck import { useState, useEffect } from 'react' import { Button, diff --git a/src/app/components/sqlblock/ChartConfigEditor.tsx b/src/app/components/sqlblock/ChartConfigEditor.tsx index fd4158f7..932039d0 100644 --- a/src/app/components/sqlblock/ChartConfigEditor.tsx +++ b/src/app/components/sqlblock/ChartConfigEditor.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * ChartConfigEditor — Éditeur de configuration de visualisation graphique. * Placé après les étapes SQL dans SqlBlockEditor (mode SELECT uniquement). diff --git a/src/app/components/sqlblock/SqlBlockEditor.tsx b/src/app/components/sqlblock/SqlBlockEditor.tsx index f00b4f5d..27e76091 100644 --- a/src/app/components/sqlblock/SqlBlockEditor.tsx +++ b/src/app/components/sqlblock/SqlBlockEditor.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * SqlBlockEditor — Éditeur visuel pour les cellules de type sqlBlock. * @@ -1021,7 +1020,7 @@ function FilterGroupUI({ group, onUpdate, onRemove, onMoveUp, onMoveDown, availa onMoveDown?: () => void availableCols: string[] depth?: number - fetchDistinctValues?: (col: string, limit: number) => Promise<{ values: string[]; hasMore: boolean }> + fetchDistinctValues?: (col: string, selectLimit: number, fromLimit?: number) => Promise<{ values: string[]; hasMore: boolean }> }) { const g = normalizeFilterGroup(group) const items = g.items ?? [] @@ -1075,7 +1074,7 @@ function FilterGroupUI({ group, onUpdate, onRemove, onMoveUp, onMoveDown, availa setItems(items.map((it, idx) => idx === i ? { kind: 'cond', cond: { ...it.cond, ...patch } } : it))} + onChange={patch => setItems(items.map((it, idx) => idx === i ? { kind: 'cond', cond: { ...(it as any).cond, ...patch } } : it))} onRemove={() => setItems(items.filter((_, idx) => idx !== i))} onMoveUp={i > 0 ? () => setItems(moveArr(items, i, -1)) : undefined} onMoveDown={i < items.length - 1 ? () => setItems(moveArr(items, i, 1)) : undefined} @@ -1109,7 +1108,7 @@ function FilterGroupUI({ group, onUpdate, onRemove, onMoveUp, onMoveDown, availa ) } -function FilterRowsStepUI({ step, availableCols, onChange, fetchDistinctValues }: { step: FilterRowsStep; availableCols: string[]; onChange: (s: FilterRowsStep) => void; fetchDistinctValues?: (col: string, limit: number) => Promise<{ values: string[]; hasMore: boolean }> }) { +function FilterRowsStepUI({ step, availableCols, onChange, fetchDistinctValues }: { step: FilterRowsStep; availableCols: string[]; onChange: (s: FilterRowsStep) => void; fetchDistinctValues?: (col: string, selectLimit: number, fromLimit?: number) => Promise<{ values: string[]; hasMore: boolean }> }) { // Normalise rétrocompat : ancien format conditions[] → groups const groups: FilterGroup[] = step.groups?.length ? step.groups @@ -1913,7 +1912,7 @@ function StepConfigModal({ step, index, availableCols, availableColTypes, onUpda availableCols: string[]; availableColTypes: Record onUpdate: (idx: number, s: SqlBlockStep) => void onClose: () => void - fetchDistinctValues?: (col: string, limit: number) => Promise<{ values: string[]; hasMore: boolean }> + fetchDistinctValues?: (col: string, selectLimit: number, fromLimit?: number) => Promise<{ values: string[]; hasMore: boolean }> otherStepNames: string[] }) { // Fermeture sur Échap @@ -2014,7 +2013,7 @@ function StepItem({ step, index, totalSteps, availableCols, availableColTypes, configOpen: boolean onConfigOpen: () => void onConfigClose: () => void - fetchDistinctValues?: (col: string, limit: number) => Promise<{ values: string[]; hasMore: boolean }> + fetchDistinctValues?: (col: string, selectLimit: number, fromLimit?: number) => Promise<{ values: string[]; hasMore: boolean }> otherStepNames: string[] }) { const [pendingDelete, setPendingDelete] = useState(false) @@ -2338,7 +2337,7 @@ function AddStepModal({ onAdd, availableCols, availableColTypes, fetchDistinctVa function handleSelectType(type: string) { setSelectedType(type) - setDraftStep(defaultStep(type) as SqlBlockStep) + setDraftStep(defaultStep(type as any) as SqlBlockStep) } function handleDraftUpdate(_idx: number, s: SqlBlockStep) { setDraftStep(s) } diff --git a/src/app/room.tsx b/src/app/room.tsx index b9851252..07404c0f 100644 --- a/src/app/room.tsx +++ b/src/app/room.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Room — Composant racine utilisant RoomShell de @sqlrooms/room-shell. * @@ -66,6 +65,7 @@ function SidebarControls() { set({ showDbEngineModal: true })} + isSelected={false} icon={DbEngineIcon} /> @@ -87,6 +87,7 @@ function SidebarControls() { window.open('https://ihatexcel.github.io/sqljob/?gist=68cd597ba5da05ceba24fb975c05384f', '_blank')} + isSelected={false} icon={BookHeartIcon} /> @@ -94,6 +95,7 @@ function SidebarControls() { setTheme(theme === 'dark' ? 'light' : 'dark')} + isSelected={false} icon={theme === 'dark' ? SunIcon : MoonIcon} /> diff --git a/src/app/store/notebookStore.ts b/src/app/store/notebookStore.ts index da1f8920..20f626aa 100644 --- a/src/app/store/notebookStore.ts +++ b/src/app/store/notebookStore.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Store Zustand principal — remplace notebookApp.ts + tous les mixins Alpine. * diff --git a/src/app/store/slices/cellsSlice.ts b/src/app/store/slices/cellsSlice.ts index 00980c70..8e5ef33b 100644 --- a/src/app/store/slices/cellsSlice.ts +++ b/src/app/store/slices/cellsSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { rawTableDataStore as _rawTableDataStore } from '../../../lib/tableDataStore' import { ConfigManager } from '../../../lib/ConfigManager' import { CellConfigService, initializeCell } from '../../../lib/CellConfigService' diff --git a/src/app/store/slices/copyPasteSlice.ts b/src/app/store/slices/copyPasteSlice.ts index 97fa6774..75820a9f 100644 --- a/src/app/store/slices/copyPasteSlice.ts +++ b/src/app/store/slices/copyPasteSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck export const createCopyPasteSlice = (set: any, get: any) => ({ diff --git a/src/app/store/slices/executionSlice.ts b/src/app/store/slices/executionSlice.ts index 0533d06b..52376acb 100644 --- a/src/app/store/slices/executionSlice.ts +++ b/src/app/store/slices/executionSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { safeEvalJs } from '../../../lib/safeEval' import { rawTableDataStore as _rawTableDataStore } from '../../../lib/tableDataStore' import { DuckDBManager } from '../../../lib/DuckDBManager' @@ -9,6 +8,9 @@ import { EChartSqlParser } from '../../../lib/EChartSqlParser' import { formatValueForInputType } from '../../../lib/utils' import { FileHandler } from '../../../lib/FileHandler' +// PizZip est chargé dynamiquement via CDNManager.loadPizZip() et exposé sur window +declare const PizZip: any; + /** Détecte si un SQL contient une instruction DDL (CREATE, DROP, ALTER, INSERT, UPDATE, DELETE…). * Utilisé pour décider si le schéma DuckDB doit être rafraîchi après exécution. */ @@ -757,10 +759,10 @@ export const createExecutionSlice = (set: any, get: any) => ({ } }, - renderIframeInContainer(cell) { - const iframe = document.getElementById('iframe-' + cell._id) + renderIframeInContainer(cell: any) { + const iframe = document.getElementById('iframe-' + cell._id) as HTMLIFrameElement | null if (iframe && cell._htmlContent) { - const doc = iframe.contentDocument || iframe.contentWindow.document + const doc = iframe.contentDocument || iframe.contentWindow!.document doc.open() doc.write(cell._htmlContent) doc.close() @@ -1235,9 +1237,9 @@ export const createExecutionSlice = (set: any, get: any) => ({ } }, - async renderPerspectiveInContainer(cell) { + async renderPerspectiveInContainer(cell: any) { const containerId = 'perspective-' + cell._id - const viewer = document.getElementById(containerId) + const viewer = document.getElementById(containerId) as any if (!viewer || !cell._arrowTable) { // Viewer absent du DOM (ex: showContent=false pendant l'exécution). diff --git a/src/app/store/slices/exportSlice.ts b/src/app/store/slices/exportSlice.ts index 00fc8609..92793ede 100644 --- a/src/app/store/slices/exportSlice.ts +++ b/src/app/store/slices/exportSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * exportSlice — gestion des exports/imports de configuration + buildExportConfig(). * Converti de exportImportMixin.ts (Alpine this-proxy) vers un slice Zustand pur. diff --git a/src/app/store/slices/filesSlice.ts b/src/app/store/slices/filesSlice.ts index a4f1bc04..aeaa9952 100644 --- a/src/app/store/slices/filesSlice.ts +++ b/src/app/store/slices/filesSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { FileHandler } from '../../../lib/FileHandler' import { ConfigManager } from '../../../lib/ConfigManager' import { DuckDBManager } from '../../../lib/DuckDBManager' @@ -6,8 +5,8 @@ import { DuckDBManager } from '../../../lib/DuckDBManager' export const createFilesSlice = (set: any, get: any) => ({ async loadEmbeddedFiles() { - const sourceFileScripts = document.querySelectorAll('script[id^="sourceFile_"]') - const docxTemplateScripts = document.querySelectorAll('script[id^="docxTemplate_"]') + const sourceFileScripts = document.querySelectorAll('script[id^="sourceFile_"]') + const docxTemplateScripts = document.querySelectorAll('script[id^="docxTemplate_"]') if (sourceFileScripts.length === 0 && docxTemplateScripts.length === 0) return diff --git a/src/app/store/slices/groupsSlice.ts b/src/app/store/slices/groupsSlice.ts index 7bcecec6..31c14347 100644 --- a/src/app/store/slices/groupsSlice.ts +++ b/src/app/store/slices/groupsSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { ConfigManager } from '../../../lib/ConfigManager' import { useConfirmModal } from '../uiStores' diff --git a/src/app/store/slices/helpersSlice.ts b/src/app/store/slices/helpersSlice.ts index d9e14bb5..bbc67d21 100644 --- a/src/app/store/slices/helpersSlice.ts +++ b/src/app/store/slices/helpersSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * helpersSlice — utilitaires, initialisation, gestion moteur DB, statuts. * Converti de helpersMixin.ts (Alpine this-proxy) vers un slice Zustand pur. diff --git a/src/app/store/slices/pagesSlice.ts b/src/app/store/slices/pagesSlice.ts index 05ee1a16..5a4fd1ba 100644 --- a/src/app/store/slices/pagesSlice.ts +++ b/src/app/store/slices/pagesSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * pagesSlice — gestion des pages (onglets) du notebook. * Converti de pagesMixin.ts (Alpine this-proxy) vers un slice Zustand pur. diff --git a/src/app/store/slices/parametersSlice.ts b/src/app/store/slices/parametersSlice.ts index 1a3d9c16..60d2f292 100644 --- a/src/app/store/slices/parametersSlice.ts +++ b/src/app/store/slices/parametersSlice.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * parametersSlice — gestion des paramètres UI et du DAG (Directed Acyclic Graph). * Converti de parametersMixin.ts (Alpine this-proxy) vers un slice Zustand pur. diff --git a/src/app/store/uiStores.ts b/src/app/store/uiStores.ts index 5b5efe35..42cfc33e 100644 --- a/src/app/store/uiStores.ts +++ b/src/app/store/uiStores.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Stores Zustand pour les modals globaux (remplace alpineStores.ts) */ diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 00000000..c4dbf59e --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,95 @@ +/** + * Déclarations globales TypeScript pour les extensions de Window et les modules sans types. + */ + +// Extensions de l'objet Window +interface Window { + // Config & Gist + _pendingEncryptedGist?: string; + _encryptedSource?: string; + _loadedConfig?: unknown; + GistEncrypt?: unknown; + + // DuckDB + duckdbModule?: unknown; + ducklingsModule?: unknown; + + // Perspective + perspectiveClient?: any; + + // Docx / templating (chargés dynamiquement) + docxtemplater?: any; + + // Web-component + __sqljobScriptUrl?: string; +} + +// Modules @univerjs/preset-sheets-table locales (sous-chemins non déclarés) +declare module '@univerjs/preset-sheets-table/lib/locales/en-US' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/fr-FR' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/zh-CN' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/zh-TW' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/ru-RU' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/ja-JP' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/es-ES' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/ca-ES' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/sk-SK' { const v: any; export default v; } +declare module '@univerjs/preset-sheets-table/lib/locales/fa-IR' { const v: any; export default v; } + +// Modules CSS sans types +declare module 'simple-datatables/dist/style.css' { + const content: string; + export default content; +} + +declare module 'easymde/dist/easymde.min.css' { + const content: string; + export default content; +} + +// Imports CDN distants +declare module 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@4.1.0/dist/cdn/perspective-viewer.js' { + const mod: unknown; + export default mod; +} +declare module 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid@4.1.0/dist/cdn/perspective-viewer-datagrid.js' { + const mod: unknown; + export default mod; +} +declare module 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-d3fc@4.1.0/dist/cdn/perspective-viewer-d3fc.js' { + const mod: unknown; + export default mod; +} +declare module 'https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-openlayers@4.1.0/dist/cdn/perspective-viewer-openlayers.js' { + const mod: unknown; + export default mod; +} +declare module 'https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.1.0/dist/cdn/perspective.js' { + const mod: { default: unknown }; + export default mod; +} + +// CDN-loaded globals +declare const XLSX: any +declare const PizZip: any + +// Monaco editor worker (Vite) +declare module 'monaco-editor/esm/vs/editor/editor.worker?worker' { + const Worker: new () => globalThis.Worker; + export default Worker; +} + +// JSX intrinsic elements pour web components +declare namespace JSX { + interface IntrinsicElements { + 'perspective-viewer': React.DetailedHTMLProps, HTMLElement> & { + ref?: React.Ref; + theme?: string; + class?: string; + }; + 'univer-sheet': React.DetailedHTMLProps, HTMLElement> & { + ref?: React.Ref; + class?: string; + }; + } +} diff --git a/src/lib/CDNManager.ts b/src/lib/CDNManager.ts index f5ab4f99..1226629c 100644 --- a/src/lib/CDNManager.ts +++ b/src/lib/CDNManager.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { EditorView } from '@codemirror/view' import { EditorState } from '@codemirror/state' import { basicSetup } from 'codemirror' @@ -20,7 +19,7 @@ export class CDNManager { return this.loadingPromises.get(url); } - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; script.onload = () => { @@ -118,7 +117,7 @@ export class CDNManager { } // Créer une instance d'éditeur CodeMirror SQL (utilisé par editorsMixin pour les modales de groupe) - static createSqlEditor(container, initialValue, onChange, options = {}) { + static createSqlEditor(container: any, initialValue: any, onChange: any, options: any = {}) { const isDarkTheme = document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches; diff --git a/src/lib/CellConfigService.ts b/src/lib/CellConfigService.ts index 4f7e6b32..3a8f999c 100644 --- a/src/lib/CellConfigService.ts +++ b/src/lib/CellConfigService.ts @@ -1,10 +1,9 @@ -// @ts-nocheck import { CELL_TYPE_SCHEMAS, CELL_TYPE_HANDLERS } from './cellTypeSchemas' import { ConfigManager } from './ConfigManager' import { FileHandler } from './FileHandler' /** Initialisation partagée d'une cellule (initCell + restore). Utilisé par notebookApp et applyImportedConfig. */ - export function initializeCell(cell, cellIndex, opts = {}) { + export function initializeCell(cell: any, cellIndex: any, opts: any = {}) { const generateId = opts.generateId || (() => 'cell_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)); const newCell = { ...cell, @@ -76,7 +75,7 @@ import { FileHandler } from './FileHandler' if (type == null) return []; return CELL_TYPE_SCHEMAS.types[type]?.specificParams || []; } - static ensureCellFromSchema(cell, type, opts = {}) { + static ensureCellFromSchema(cell: any, type: any, opts: any = {}) { const schema = CELL_TYPE_SCHEMAS.types[type]; if (!schema || !cell) return; const { baseName } = opts; @@ -118,7 +117,7 @@ import { FileHandler } from './FileHandler' cell.json = JSON.stringify(cell.json, null, 2); } } - static applyDefaultsOnTypeChange(cell, newType, opts = {}) { + static applyDefaultsOnTypeChange(cell: any, newType: any, opts: any = {}) { if (!cell) return; const schema = CELL_TYPE_SCHEMAS.types[newType]; if (!schema) return; diff --git a/src/lib/ConfigManager.ts b/src/lib/ConfigManager.ts index f7744a29..e7abc6d4 100644 --- a/src/lib/ConfigManager.ts +++ b/src/lib/ConfigManager.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { CELL_TYPE_SCHEMAS, CELL_TYPE_HANDLERS } from './cellTypeSchemas' import { GistEncrypt } from './GistEncrypt' import { FileHandler } from './FileHandler' @@ -127,7 +126,7 @@ return c; } /** Retourne la requête par nom (ex: 'main', 'fallback', 'filename'). Rétrocompat: index 0->main, 1->fallback/filename. */ - static getCellQuery(cell, nameOrIndex = 'main') { + static getCellQuery(cell: any, nameOrIndex: string | number = 'main') { if (!cell) return ''; const name = typeof nameOrIndex === 'number' ? (ConfigManager.getQueryNameForIndex(cell, nameOrIndex) || 'main') : nameOrIndex; const q = ConfigManager.getQueryByName(cell, name); @@ -137,7 +136,7 @@ return c; /** * Indique si l'éditeur SQL est visible pour cette requête (showQueryEditor). */ - static getCellQueryShowQueryEditor(cell, nameOrIndex = 'main') { + static getCellQueryShowQueryEditor(cell: any, nameOrIndex: string | number = 'main') { if (!cell) return false; const name = typeof nameOrIndex === 'number' ? (ConfigManager.getQueryNameForIndex(cell, nameOrIndex) || 'main') : nameOrIndex; const q = ConfigManager.getQueryByName(cell, name); @@ -148,7 +147,7 @@ return c; * Indique si le résultat (datatable/visualisation) doit être affiché en mode client. * Par défaut true (undefined = affiché). */ - static getCellQueryShowResult(cell, nameOrIndex = 'main') { + static getCellQueryShowResult(cell: any, nameOrIndex: string | number = 'main') { if (!cell) return true; const name = typeof nameOrIndex === 'number' ? (ConfigManager.getQueryNameForIndex(cell, nameOrIndex) || 'main') : nameOrIndex; const q = ConfigManager.getQueryByName(cell, name); @@ -334,7 +333,7 @@ return c; } /** Retourne le moteur de la requête (queries[].engine). Valeurs: sql, js, text. Défaut depuis schéma. */ - static getCellEngine(cell, nameOrIndex = 'main') { + static getCellEngine(cell: any, nameOrIndex: string | number = 'main') { if (!cell) return 'sql'; const name = typeof nameOrIndex === 'number' ? (ConfigManager.getQueryNameForIndex(cell, nameOrIndex) || 'main') : nameOrIndex; const q = ConfigManager.getQueryByName(cell, name); @@ -448,7 +447,7 @@ return c; */ static getUIParamsFromURL() { const urlParams = new URLSearchParams(window.location.search); - const uiParams = {}; + const uiParams: Record = {}; // devMode: boolean if (urlParams.has('devMode')) { @@ -566,7 +565,7 @@ return c; const response = await fetch(apiUrl); if (response.ok) { const gistData = await response.json(); - const files = Object.values(gistData.files || {}); + const files = Object.values(gistData.files || {}) as any[]; const jsonFile = files.find(file => file.filename.toLowerCase().endsWith('.json') || file.type === 'application/json' @@ -637,8 +636,8 @@ return c; return arr; } - static async cleanCell(cell, includeFileData = false) { - const cleanCell = { type: cell.type }; + static async cleanCell(cell: any, includeFileData = false) { + const cleanCell: any = { type: cell.type }; const schema = CELL_TYPE_SCHEMAS?.types[cell?.type]; const exportFields = schema?.exportFields ?? ['queries']; @@ -737,8 +736,8 @@ return c; return cleanCell; } - static async cleanGroup(group, includeFileData = false) { - const cleanGroup = { + static async cleanGroup(group: any, includeFileData = false) { + const cleanGroup: any = { direction: group.direction || 'row', style: group.style || '', cells: await Promise.all((group.cells || []).map(cell => ConfigManager.cleanCell(cell, includeFileData))) diff --git a/src/lib/EChartSqlParser.ts b/src/lib/EChartSqlParser.ts index cf096b27..60b1a4cc 100644 --- a/src/lib/EChartSqlParser.ts +++ b/src/lib/EChartSqlParser.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // EChartSqlParser - Converts SQL query results (with column alias roles) // into Apache ECharts option objects. // diff --git a/src/lib/FileHandler.ts b/src/lib/FileHandler.ts index 572717cf..c7057f90 100644 --- a/src/lib/FileHandler.ts +++ b/src/lib/FileHandler.ts @@ -1,6 +1,8 @@ -// @ts-nocheck import { CDNManager } from './CDNManager' +// XLSX est chargé dynamiquement via CDNManager.loadXlsx() et exposé sur window +declare const XLSX: any; + export class FileHandler { static getMimeTypeFromFileName(fileName) { const ext = fileName.split('.').pop().toLowerCase(); diff --git a/src/lib/GistEncrypt.ts b/src/lib/GistEncrypt.ts index 2c1bfa85..9facf66f 100644 --- a/src/lib/GistEncrypt.ts +++ b/src/lib/GistEncrypt.ts @@ -1,4 +1,3 @@ -// @ts-nocheck export class GistEncrypt { static ENCRYPTED_MARKER = '_encrypted'; diff --git a/src/lib/GitHubGistManager.ts b/src/lib/GitHubGistManager.ts index fe8be311..0ad0a817 100644 --- a/src/lib/GitHubGistManager.ts +++ b/src/lib/GitHubGistManager.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { GistEncrypt } from './GistEncrypt' export class GitHubGistManager { diff --git a/src/lib/SqlBlockTypes.ts b/src/lib/SqlBlockTypes.ts index 18ed773d..5b87cb1e 100644 --- a/src/lib/SqlBlockTypes.ts +++ b/src/lib/SqlBlockTypes.ts @@ -309,6 +309,7 @@ export interface SqlBlockConfig { ast: SqlBlockAst; degraded: boolean; manualSql: string | null; + sql?: string; } // ─── Constants ──────────────────────────────────────────────────────────────── diff --git a/src/lib/cellTypeSchemas.ts b/src/lib/cellTypeSchemas.ts index 7b61c6a4..bd413e6f 100644 --- a/src/lib/cellTypeSchemas.ts +++ b/src/lib/cellTypeSchemas.ts @@ -1,4 +1,3 @@ -// @ts-nocheck export const CELL_TYPE_SCHEMAS = { common: { diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 9d5bd58a..57ed7a7e 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Icônes centralisées — migration Iconify → lucide-react (système natif sqlrooms). * Utilisé uniquement dans les composants React. diff --git a/src/lib/safeEval.ts b/src/lib/safeEval.ts index afcd472b..7a98bba7 100644 --- a/src/lib/safeEval.ts +++ b/src/lib/safeEval.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Évalue une expression JavaScript dans un sandbox restreint. diff --git a/src/lib/utils.ts b/src/lib/utils.ts index dba93c8e..c3628ebe 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,3 @@ -// @ts-nocheck export function ico(name: string, size = '1rem'): string { return ``; diff --git a/src/web-component/sqljob-app.ts b/src/web-component/sqljob-app.ts index da8b7004..e63641c4 100644 --- a/src/web-component/sqljob-app.ts +++ b/src/web-component/sqljob-app.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Web Component CDN : * diff --git a/tsconfig.json b/tsconfig.json index 07f267da..8eda79e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "moduleDetection": "force", "noEmit": true, - /* Linting - relâché pour Phase 1 */ + /* Linting */ "strict": false, "noUnusedLocals": false, "noUnusedParameters": false, From fcf4d135de34bd7cbfc53cb27935b41073257bf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 17:20:25 +0000 Subject: [PATCH 16/20] docs: corrige licence AGPL-3.0 + ajoute positionnement du projet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Licence : MIT → AGPL-3.0 (licence réelle du dépôt) - Ajout section Purpose : moulinette ETL / calculette partageable, no GAFAM / no cloud, export HTML standalone avec données en Base64 https://claude.ai/code/session_01NJyCeZ7b1joKmkwinsMtmK --- README.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f48c3e3c..5c383a10 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,33 @@ # sqljob -Client-side SQL notebook powered by DuckDB-WASM. Load files, write SQL, build charts and reports — entirely in the browser, no server required. +A browser-based tool for building shareable ETL pipelines and calculator interfaces — no server, no GAFAM, no cloud dependency. **[Try it →](https://ihatexcel.github.io/sqljob)** --- +## Purpose + +sqljob is designed for analysts and developers who need to build and share **data processing workflows** ("moulinettes") without relying on cloud infrastructure or proprietary tools. + +The typical use case: you write SQL transformations, wire up input parameters, and package the whole thing as a single portable file — a JSON configuration, or a standalone HTML that embeds both the app and the data (Base64-encoded). The recipient opens it in a browser. No login, no server, no Excel. + +Use it to build: +- ETL pipelines that run entirely client-side (DuckDB-WASM) +- Calculator or simulation interfaces driven by SQL +- Self-contained data reports shareable as a single HTML file +- Parameterized notebooks distributed via GitHub Gist (with optional encryption) + +--- + ## What it does - Load CSV, Parquet, and Excel files directly into DuckDB - Write SQL across multiple notebook pages with grouped, auto-executable cells - Generate charts using SQL role syntax: `SELECT date::XAXIS, revenue::BARCHART FROM sales` - Build dynamic reports with parameters, conditional groups, and Univer spreadsheet cells -- Export to standalone HTML, PDF, or share via GitHub Gist (with optional encryption) +- Export to standalone HTML (data embedded as Base64), JSON config, or PDF +- Share via GitHub Gist with optional passphrase encryption - Embed as a web component via CDN: `` --- @@ -79,4 +94,4 @@ src/ ## License -MIT — © Théo Nobella-Pichonnier +AGPL-3.0 — © Théo Nobella-Pichonnier From 5c9cb94e4dec288c7b778abdc64941e12de79a9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 09:25:57 +0000 Subject: [PATCH 17/20] fix(duckdb): align CDN version with sqlrooms (1.32.0, not 1.5.2) @sqlrooms/duckdb pins @duckdb/duckdb-wasm@1.32.0; 1.5.2 doesn't exist on jsDelivr and causes a 404 at runtime. https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- src/lib/DuckDBManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/DuckDBManager.ts b/src/lib/DuckDBManager.ts index c6469357..77826847 100644 --- a/src/lib/DuckDBManager.ts +++ b/src/lib/DuckDBManager.ts @@ -7,7 +7,7 @@ static workerRef = null; // Versions et URLs des CDN (chargés dynamiquement selon le moteur) - static DUCKDB_WASM_VERSION = '1.5.2'; + static DUCKDB_WASM_VERSION = '1.32.0'; static DUCKLINGS_VERSION = '1.4.4'; static getDuckDBWasmUrl() { From 5c18f62dcced975b73512f5ab6e90ac8d7d3a370 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 10:16:03 +0000 Subject: [PATCH 18/20] refactor: apply sqlrooms contributing guidelines (TypeScript + patterns) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript (typescript.md): - Replace all Array with T[] (6 occurrences across 4 files) - Add React.FC + separate prop type for PivotBody and ResultInfo - Add typed props: PivotBodyProps, ResultInfoProps, NotebookCell, DuckdbTableInfo, PivotCellJson — replace every `any` in PivotBody - Type useShallow selectors with inline store shape instead of `(s: any)` Patterns (patterns.md): - Add immer as direct dependency (was transitive-only) - Use produce() for PivotBody cell.json mutations (setSource/setConfig callbacks now produce a new object immutably) - Replace `cell._resultInfo` (unknown) with String() cast for ReactNode https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- package.json | 1 + src/app/components/CellBody.tsx | 68 +++++++++++++------ src/app/components/modals/CellConfigModal.tsx | 6 +- src/app/store/notebookStore.ts | 2 +- src/lib/EChartSqlParser.ts | 4 +- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index f14d30ee..59627353 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "codemirror": "^6.0.2", "docxtemplater": "^3.67.6", "easymde": "^2.20.0", + "immer": "^11.1.4", "echarts": "^5.5.1", "eruda": "^3.4.3", "lit": "^3.3.2", diff --git a/src/app/components/CellBody.tsx b/src/app/components/CellBody.tsx index 65e66b93..0c70e05e 100644 --- a/src/app/components/CellBody.tsx +++ b/src/app/components/CellBody.tsx @@ -19,8 +19,26 @@ import { DuckDBManager } from '../../lib/DuckDBManager' import DataTablePaginated from '@sqlrooms/data-table/dist/DataTablePaginated' import { SqlBlockEditor } from './sqlblock/SqlBlockEditor' import { PivotEditor } from '@sqlrooms/pivot' +import type { PivotConfig, PivotField, PivotQuerySource, PivotSource } from '@sqlrooms/pivot' +import { produce } from 'immer' import './UniverSheetElement' +// ─── Types locaux ────────────────────────────────────────────────────────────── +type DuckdbTableInfo = { rowCount: number; columns: PivotField[] } + +type PivotCellJson = { + selectedTable?: string + pivotConfig?: PivotConfig +} + +type NotebookCell = { + _id: string + type: string + name?: string + json?: PivotCellJson & Record + [key: string]: unknown +} + // ─── Skeleton ───────────────────────────────────────────────────────────────── function CellBodySkeleton() { return ( @@ -45,12 +63,14 @@ function TableSkeleton() { } // ─── ResultInfo ─────────────────────────────────────────────────────────────── -function ResultInfo({ cell, devOnly = false }: { cell: any, devOnly?: boolean }) { +type ResultInfoProps = { cell: NotebookCell; devOnly?: boolean } + +const ResultInfo: React.FC = ({ cell, devOnly = false }) => { const devMode = useNotebookStore(s => s.devMode) if (!cell._resultInfo) return null if (!String(cell._resultInfo).startsWith('❌')) return null if (devOnly && !devMode) return null - return
{cell._resultInfo}
+ return
{String(cell._resultInfo)}
} // ─── MarkdownBody ───────────────────────────────────────────────────────────── @@ -1192,38 +1212,46 @@ function UniverSheetBody({ cell, path, cellIndex }: any) { } // ─── PivotBody ──────────────────────────────────────────────────────────────── -function PivotBody({ cell }: any) { - const { _duckdbTables, forceUpdate, devMode } = useNotebookStore(useShallow((s: any) => ({ +type PivotBodyProps = { cell: NotebookCell } + +const PivotBody: React.FC = ({ cell }) => { + const { _duckdbTables, forceUpdate, devMode } = useNotebookStore(useShallow((s: { + _duckdbTables: Record + forceUpdate: () => void + devMode: boolean + }) => ({ _duckdbTables: s._duckdbTables, forceUpdate: s.forceUpdate, devMode: s.devMode, }))) - const availableTables = useMemo(() => Object.keys(_duckdbTables || {}), [_duckdbTables]) + const availableTables = useMemo(() => Object.keys(_duckdbTables), [_duckdbTables]) - const selectedTable: string = cell.json?.selectedTable || '' + const selectedTable = cell.json?.selectedTable ?? '' - const querySource = useMemo(() => { - if (!selectedTable || !_duckdbTables?.[selectedTable]) return undefined - const cols = _duckdbTables[selectedTable].columns || [] - return { tableRef: `"${selectedTable}"`, columns: cols } + const querySource = useMemo((): PivotQuerySource | undefined => { + if (!selectedTable || !_duckdbTables[selectedTable]) return undefined + const columns = _duckdbTables[selectedTable].columns + return { tableRef: `"${selectedTable}"`, columns } }, [selectedTable, _duckdbTables]) - const source = useMemo(() => - selectedTable ? { kind: 'table' as const, tableName: selectedTable } : undefined, + const source = useMemo((): PivotSource | undefined => + selectedTable ? { kind: 'table', tableName: selectedTable } : undefined, [selectedTable] ) const callbacks = useMemo(() => ({ - setSource: (src: any) => { - if (!cell.json) cell.json = {} - cell.json.selectedTable = src?.kind === 'table' ? src.tableName : '' - cell.json.pivotConfig = undefined // reset config when table changes + setSource: (src: PivotSource | undefined) => { + cell.json = produce(cell.json ?? {}, (draft: PivotCellJson) => { + draft.selectedTable = src?.kind === 'table' ? src.tableName : '' + draft.pivotConfig = undefined + }) forceUpdate() }, - setConfig: (config: any) => { - if (!cell.json) cell.json = {} - cell.json.pivotConfig = config + setConfig: (config: PivotConfig) => { + cell.json = produce(cell.json ?? {}, (draft: PivotCellJson) => { + draft.pivotConfig = config + }) forceUpdate() }, }), [cell, forceUpdate]) @@ -1288,7 +1316,7 @@ export function CellBody({ cell, path, cellIndex, group }: { cell: any, path: nu case 'iframe': return case 'sqlStat': return - case 'pivot': return + case 'pivot': return case 'uiParameter': return case 'publipostageWord': diff --git a/src/app/components/modals/CellConfigModal.tsx b/src/app/components/modals/CellConfigModal.tsx index c6373f18..493891e3 100644 --- a/src/app/components/modals/CellConfigModal.tsx +++ b/src/app/components/modals/CellConfigModal.tsx @@ -113,7 +113,7 @@ function UniverConfigEditor({ cell, forceUpdate }: any) { { key: 'header', label: 'En-tête' }, { key: 'contextMenu', label: 'Menu contextuel' }, { key: 'disableAutoFocus', label: "Désactiver l'auto-focus", invert: true }, - ] as Array<{ key: string; label: string; invert?: boolean }>).map(({ key, label, invert }) => ( + ] as { key: string; label: string; invert?: boolean }[]).map(({ key, label, invert }) => (
).map(({ key, label }) => ( + ] as { key: string; label: string }[]).map(({ key, label }) => (
).map(({ key, label }) => ( + ] as { key: string; label: string }[]).map(({ key, label }) => (
, - _roomFiles: [] as Array<{name: string, tableName: string, size: number, source: 'dropzone' | 'source-cell'}>, + _roomFiles: [] as {name: string, tableName: string, size: number, source: 'dropzone' | 'source-cell'}[], _rev: 0, // compteur de version pour forcer les re-renders } } diff --git a/src/lib/EChartSqlParser.ts b/src/lib/EChartSqlParser.ts index 60b1a4cc..7bfbafd9 100644 --- a/src/lib/EChartSqlParser.ts +++ b/src/lib/EChartSqlParser.ts @@ -791,7 +791,7 @@ function _buildGaugeOption(results, roleMap, chartType, base, textColor) { // LABELS column: [{value, label}, ...] OR simple ['label1', 'label2', ...] const labelsCols = roleMap['LABELS'] || []; const labelsCol = labelsCols[0]?.originalName; - let gaugeAxisLabels: Array<{ value: number; label: string }> | undefined; + let gaugeAxisLabels: { value: number; label: string }[] | undefined; if (labelsCol && results[0]?.[labelsCol] != null) { const raw = results[0][labelsCol]; try { @@ -981,7 +981,7 @@ function _buildBoxplotOption(results, roleMap, base, textColor) { let categories: string[] = []; let boxData: number[][] = []; - let outlierData: Array<[number, number]> = []; // [catIndex, value] + let outlierData: [number, number][] = []; // [catIndex, value] // If 5+ BOXPLOT columns, treat as direct [min, q1, median, q3, max] — no outlier detection if (valueCols.length >= 5) { From 03ddc5a23ed292b4cb6fa3cc1ddbc93f8c681cd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 10:43:40 +0000 Subject: [PATCH 19/20] refactor: apply sqlrooms contributing guidelines (typescript.md / patterns.md) - Create NotebookStoreState interface in src/app/store/types.ts with all slice methods - Export typed useNotebookStore/roomStore wrappers (cast from internal any store) - Convert CellBody.tsx components to React.FC with separate typed props - Replace (s: any) selectors with (s: NotebookStoreState) in components - Remove unnecessary (get() as any) casts in exportSlice and executionSlice - Add TODO comments for future produce() adoption in cellsSlice https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- src/app/App.tsx | 3 +- src/app/components/CellBody.tsx | 77 +-- src/app/components/NotebookPanelSqlrooms.tsx | 9 +- src/app/components/modals/CellConfigModal.tsx | 4 +- src/app/components/modals/GroupModals.tsx | 2 +- src/app/room.tsx | 3 +- src/app/store/notebookStore.ts | 21 +- src/app/store/slices/cellsSlice.ts | 2 + src/app/store/slices/executionSlice.ts | 2 +- src/app/store/slices/exportSlice.ts | 2 +- src/app/store/types.ts | 523 ++++++++++++++++++ 11 files changed, 602 insertions(+), 46 deletions(-) create mode 100644 src/app/store/types.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index e6989be8..637fdb99 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -30,7 +30,8 @@ function AppContent() { // déléguer à l'action Zustand du notebookStore useTemplateModal.setState({ _onSelectTemplate: (cellId, queryType, templateIndex, languageType) => { - useNotebookStore.getState().applyTemplateToCell?.(cellId, queryType, templateIndex, languageType) + // TODO: type applyTemplateToCell — comes from sqlrooms notebook slice + ;(useNotebookStore.getState() as any).applyTemplateToCell?.(cellId, queryType, templateIndex, languageType) } }) diff --git a/src/app/components/CellBody.tsx b/src/app/components/CellBody.tsx index 0c70e05e..f9281d44 100644 --- a/src/app/components/CellBody.tsx +++ b/src/app/components/CellBody.tsx @@ -21,22 +21,21 @@ import { SqlBlockEditor } from './sqlblock/SqlBlockEditor' import { PivotEditor } from '@sqlrooms/pivot' import type { PivotConfig, PivotField, PivotQuerySource, PivotSource } from '@sqlrooms/pivot' import { produce } from 'immer' +import type { NotebookCell, DuckdbTableInfo } from '../store/types' import './UniverSheetElement' // ─── Types locaux ────────────────────────────────────────────────────────────── -type DuckdbTableInfo = { rowCount: number; columns: PivotField[] } type PivotCellJson = { selectedTable?: string pivotConfig?: PivotConfig } -type NotebookCell = { - _id: string - type: string - name?: string - json?: PivotCellJson & Record - [key: string]: unknown +// ─── Shared cell body props ──────────────────────────────────────────────────── +type CellBodyBaseProps = { + cell: NotebookCell + path: number[] + cellIndex: number } // ─── Skeleton ───────────────────────────────────────────────────────────────── @@ -74,7 +73,7 @@ const ResultInfo: React.FC = ({ cell, devOnly = false }) => { } // ─── MarkdownBody ───────────────────────────────────────────────────────────── -function MarkdownBody({ cell, path, cellIndex }: any) { +const MarkdownBody: React.FC = ({ cell, path, cellIndex }) => { const devMode = useNotebookStore(s => s.devMode) const easyMDERef = useRef(null) const hasCellHeight = useNotebookStore(s => s.hasCellHeight) @@ -89,7 +88,7 @@ function MarkdownBody({ cell, path, cellIndex }: any) { CDNManager.loadEasyMDE().then(() => { if (!el.parentElement) return if (cell._easyMDEcli) { - try { cell._easyMDEcli.toTextArea() } catch (_) {} + try { (cell._easyMDEcli as any).toTextArea() } catch (_) {} cell._easyMDEcli = null } inst = new (window as any).EasyMDE({ @@ -200,7 +199,7 @@ function RejectErrorsModal({ open, onClose }: { open: boolean; onClose: () => vo } // ─── SourceBody (file drop zone) ───────────────────────────────────────────── -function SourceBody({ cell, path, cellIndex }: any) { +const SourceBody: React.FC = ({ cell, path, cellIndex }) => { const { handleSingleSourceDrop, handleSingleSourceFileSelect, downloadSourceFile, removeSingleSourceFile, devMode, forceUpdate @@ -354,7 +353,7 @@ function SourceBody({ cell, path, cellIndex }: any) { } // ─── ButtonRunBody ──────────────────────────────────────────────────────────── -function ButtonRunBody({ cell, path, cellIndex }: any) { +const ButtonRunBody: React.FC = ({ cell, path, cellIndex }) => { const { runCellsAfter, isLoading } = useNotebookStore(useShallow(s => ({ runCellsAfter: s.runCellsAfter, isLoading: s.isLoading }))) return (
@@ -402,7 +401,8 @@ function EChartRenderer({ cell, hasHeight }: { cell: any; hasHeight: boolean }) } // ─── SqlTableBody ───────────────────────────────────────────────────────────── -function SqlTableBody({ cell, path, cellIndex, showTextResult = false }: any) { +type SqlTableBodyProps = CellBodyBaseProps & { showTextResult?: boolean } +const SqlTableBody: React.FC = ({ cell, path, cellIndex, showTextResult = false }) => { const { devMode, hasCellHeight, showSqlEditorVisible, isSqlResultTabular, isSqlResultText, @@ -587,7 +587,7 @@ function SqlTableBody({ cell, path, cellIndex, showTextResult = false }: any) { } // ─── IframeBody ─────────────────────────────────────────────────────────────── -function IframeBody({ cell, path, cellIndex }: any) { +const IframeBody: React.FC = ({ cell, path, cellIndex }) => { const { devMode, hasCellHeight, showSqlEditorVisible, _rev } = useNotebookStore(useShallow(s => ({ devMode: s.devMode, hasCellHeight: s.hasCellHeight, @@ -627,7 +627,7 @@ function IframeBody({ cell, path, cellIndex }: any) { } // ─── SqlStatBody ────────────────────────────────────────────────────────────── -function SqlStatBody({ cell, path, cellIndex }: any) { +const SqlStatBody: React.FC = ({ cell, path, cellIndex }) => { const { devMode, showSqlEditorVisible } = useNotebookStore(useShallow(s => ({ devMode: s.devMode, showSqlEditorVisible: s.showSqlEditorVisible @@ -669,7 +669,7 @@ function SqlStatBody({ cell, path, cellIndex }: any) { } // ─── UiParameterBody ────────────────────────────────────────────────────────── -function UiParameterBody({ cell, path, cellIndex }: any) { +const UiParameterBody: React.FC = ({ cell, path, cellIndex }) => { const { devMode, onParameterValueChange } = useNotebookStore(useShallow(s => ({ devMode: s.devMode, onParameterValueChange: s.onParameterValueChange, @@ -681,11 +681,13 @@ function UiParameterBody({ cell, path, cellIndex }: any) { const badgeClass = isJs ? 'badge-warning' : isText ? 'badge-ghost' : 'badge-info' const placeholder = isJs ? 'return ["Option 1", "Option 2"];' : isText ? 'Saisir le texte' : 'SELECT * from source1' - const [localValue, setLocalValue] = useState(cell._value ?? '') + const [localValue, setLocalValue] = useState( + (cell._value as string | number | undefined) ?? '' + ) // Sync la valeur locale quand cell._value change suite à une exécution externe useEffect(() => { - setLocalValue(cell._value ?? '') + setLocalValue((cell._value as string | number | undefined) ?? '') }, [cell._value]) if (!devMode && cell.userVisible === false) return null @@ -761,7 +763,7 @@ function UiParameterBody({ cell, path, cellIndex }: any) { } // ─── PublipostageWordBody ───────────────────────────────────────────────────── -function PublipostageWordBody({ cell, path, cellIndex }: any) { +const PublipostageWordBody: React.FC = ({ cell, path, cellIndex }) => { const { handleDocxTemplateDrop, handleDocxTemplateFileSelect, downloadDocxTemplate, removeDocxTemplate, @@ -860,7 +862,7 @@ function PublipostageWordBody({ cell, path, cellIndex }: any) { } // ─── PdfmeBody ──────────────────────────────────────────────────────────────── -function PdfmeBody({ cell, path, cellIndex }: any) { +const PdfmeBody: React.FC = ({ cell, path, cellIndex }) => { const { runCellAt, isLoading, devMode, forceUpdate } = useNotebookStore(useShallow(s => ({ runCellAt: s.runCellAt, isLoading: s.isLoading, @@ -909,8 +911,8 @@ function PdfmeBody({ cell, path, cellIndex }: any) { rows={10} style={{ minHeight: '180px' }} placeholder='{"basePdf": {...}, "schemas": [...]}' - value={cell.json || ''} - onChange={e => { cell.json = e.target.value; forceUpdate() }} + value={(cell.json as unknown as string | undefined) || ''} + onChange={e => { cell.json = e.target.value as unknown as Record; forceUpdate() }} /> @@ -933,7 +935,7 @@ function PdfmeBody({ cell, path, cellIndex }: any) { } // ─── PerspectiveBody ────────────────────────────────────────────────────────── -function PerspectiveBody({ cell, path, cellIndex }: any) { +const PerspectiveBody: React.FC = ({ cell, path, cellIndex }) => { const { devMode, showSqlEditorVisible, hasCellHeight, renderPerspectiveInContainer, runCellAt, refreshDuckdbTables, _rev } = useNotebookStore(useShallow(s => ({ devMode: s.devMode, showSqlEditorVisible: s.showSqlEditorVisible, @@ -1047,7 +1049,7 @@ function PerspectiveBody({ cell, path, cellIndex }: any) { } // ─── GenericHtmlBody (fallback) ─────────────────────────────────────────────── -function GenericHtmlBody({ cell, path, cellIndex }: any) { +const GenericHtmlBody: React.FC = ({ cell, path, cellIndex }) => { const { devMode, showSqlEditorVisible } = useNotebookStore(useShallow(s => ({ devMode: s.devMode, showSqlEditorVisible: s.showSqlEditorVisible @@ -1073,7 +1075,7 @@ function GenericHtmlBody({ cell, path, cellIndex }: any) { } // ─── UniverSheetBody ────────────────────────────────────────────────────────── -function UniverSheetBody({ cell, path, cellIndex }: any) { +const UniverSheetBody: React.FC = ({ cell, path, cellIndex }) => { const { devMode, showSqlEditorVisible, hasCellHeight, captureUniverSnapshot, exportUniverToXlsx, _rev, @@ -1099,7 +1101,7 @@ function UniverSheetBody({ cell, path, cellIndex }: any) { if (!cell._univerReady || !elementRef.current) return if (cell._univerRunId === lastInitRunId.current) return lastInitRunId.current = cell._univerRunId ?? 0 - const _univerCfg = cell.json?.univerConfig + const _univerCfg = cell.json?.univerConfig as { materializeAsDuckDB?: boolean } | undefined const _materialize = _univerCfg && typeof _univerCfg === 'object' ? !!_univerCfg.materializeAsDuckDB : false elementRef.current.initialize({ rows: cell._univerRows ?? null, @@ -1227,7 +1229,7 @@ const PivotBody: React.FC = ({ cell }) => { const availableTables = useMemo(() => Object.keys(_duckdbTables), [_duckdbTables]) - const selectedTable = cell.json?.selectedTable ?? '' + const selectedTable = (cell.json?.selectedTable as string | undefined) ?? '' const querySource = useMemo((): PivotQuerySource | undefined => { if (!selectedTable || !_duckdbTables[selectedTable]) return undefined @@ -1242,16 +1244,22 @@ const PivotBody: React.FC = ({ cell }) => { const callbacks = useMemo(() => ({ setSource: (src: PivotSource | undefined) => { - cell.json = produce(cell.json ?? {}, (draft: PivotCellJson) => { - draft.selectedTable = src?.kind === 'table' ? src.tableName : '' - draft.pivotConfig = undefined - }) + cell.json = produce( + (cell.json as PivotCellJson | undefined) ?? {}, + (draft: PivotCellJson) => { + draft.selectedTable = src?.kind === 'table' ? src.tableName : '' + draft.pivotConfig = undefined + } + ) as Record forceUpdate() }, setConfig: (config: PivotConfig) => { - cell.json = produce(cell.json ?? {}, (draft: PivotCellJson) => { - draft.pivotConfig = config - }) + cell.json = produce( + (cell.json as PivotCellJson | undefined) ?? {}, + (draft: PivotCellJson) => { + draft.pivotConfig = config + } + ) as Record forceUpdate() }, }), [cell, forceUpdate]) @@ -1285,7 +1293,8 @@ const PivotBody: React.FC = ({ cell }) => { } // ─── CellBody principal ─────────────────────────────────────────────────────── -export function CellBody({ cell, path, cellIndex, group }: { cell: any, path: number[], cellIndex: number, group: any }) { +type CellBodyProps = CellBodyBaseProps & { group: NotebookCell | Record } +export const CellBody: React.FC = ({ cell, path, cellIndex, group }) => { const { devMode, isLoading, hasCellHeight, getCellHeightVars, diff --git a/src/app/components/NotebookPanelSqlrooms.tsx b/src/app/components/NotebookPanelSqlrooms.tsx index 42b44dae..8186656d 100644 --- a/src/app/components/NotebookPanelSqlrooms.tsx +++ b/src/app/components/NotebookPanelSqlrooms.tsx @@ -11,12 +11,15 @@ import { Canvas } from '@sqlrooms/canvas' import { Button } from '@sqlrooms/ui' import { ArrowLeftRightIcon } from 'lucide-react' import { useNotebookStore } from '../store/notebookStore' +import type { NotebookStoreState } from '../store/types' // ─── NotebookPanelSqlrooms ──────────────────────────────────────────────────── export const NotebookPanelSqlrooms = ({ onSwitchPanel }: { onSwitchPanel: () => void }) => { - const currentSheetType = useNotebookStore((s: any) => { - const id = s.cells?.config?.currentSheetId - return id ? s.cells?.config?.sheets?.[id]?.type : 'notebook' + const currentSheetType = useNotebookStore((s: NotebookStoreState) => { + // TODO: type this properly — s.cells comes from the sqlrooms cells slice (not in NotebookStoreState) + const cells = s.cells as { config?: { currentSheetId?: string; sheets?: Record } } | undefined + const id = cells?.config?.currentSheetId + return id ? cells?.config?.sheets?.[id]?.type : 'notebook' }) return ( diff --git a/src/app/components/modals/CellConfigModal.tsx b/src/app/components/modals/CellConfigModal.tsx index 493891e3..758ac22d 100644 --- a/src/app/components/modals/CellConfigModal.tsx +++ b/src/app/components/modals/CellConfigModal.tsx @@ -430,7 +430,7 @@ export function CellConfigModal() {
{ cell[k] = e.target.value; forceUpdate() }} />
))} @@ -439,7 +439,7 @@ export function CellConfigModal() {
{ cell[k] = e.target.value; forceUpdate() }} />
))} diff --git a/src/app/components/modals/GroupModals.tsx b/src/app/components/modals/GroupModals.tsx index e40d0491..a56dd754 100644 --- a/src/app/components/modals/GroupModals.tsx +++ b/src/app/components/modals/GroupModals.tsx @@ -45,7 +45,7 @@ export function LoopConfigModal() { { group.loop = group.loop || {}; group.loop.enabled = v; forceUpdate() }} + onCheckedChange={v => { group.loop = group.loop || { enabled: false, query: '', zip: false, zipQuery: '' }; group.loop.enabled = v; forceUpdate() }} />
diff --git a/src/app/room.tsx b/src/app/room.tsx index ebfd45b3..91fac74e 100644 --- a/src/app/room.tsx +++ b/src/app/room.tsx @@ -118,7 +118,8 @@ export function Room() { return ( <> - + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + diff --git a/src/app/store/notebookStore.ts b/src/app/store/notebookStore.ts index 34e70e90..4ea1d9df 100644 --- a/src/app/store/notebookStore.ts +++ b/src/app/store/notebookStore.ts @@ -7,6 +7,7 @@ */ import { setAutoFreeze } from 'immer' import { createRoomShellSlice, createRoomStore, persistSliceConfigs, LayoutConfig } from '@sqlrooms/room-shell' +import type { NotebookStoreState } from './types' import { createBaseDuckDbConnector } from '@sqlrooms/duckdb-core' import { createSqlEditorSlice, createDefaultSqlEditorConfig } from '@sqlrooms/sql-editor' import { createCellsSlice as createSqlroomsCellsSlice, createDefaultCellRegistry } from '@sqlrooms/cells' @@ -275,7 +276,11 @@ const duckdbManagerConnector = createBaseDuckDbConnector( ) // ─── Store Zustand ──────────────────────────────────────────────────────────── -export const { roomStore, useRoomStore: useNotebookStore } = createRoomStore( +// createRoomStore is required because the sqlrooms internal slices +// (createSqlEditorSlice, createRoomShellSlice, etc.) each expect their own +// slice-specific set/get types which cannot be unified without a full rewrite. +// The public API is typed via the typed re-export of useNotebookStore below. +const { roomStore: _roomStore, useRoomStore: _useNotebookStore } = createRoomStore( persistSliceConfigs( { name: 'sqljob-layout-state-v1', @@ -421,7 +426,7 @@ export const { roomStore, useRoomStore: useNotebookStore } = createRoomStore (required by sqlrooms slice type +// constraints). We re-export a typed wrapper so selectors in components can use +// (s: NotebookStoreState) instead of (s: any). +import type { StoreApi, UseBoundStore } from 'zustand' + +export const roomStore = _roomStore as unknown as StoreApi + +// Cast the hook to the typed state so all selector callbacks receive +// NotebookStoreState instead of any. +export const useNotebookStore = _useNotebookStore as unknown as UseBoundStore> diff --git a/src/app/store/slices/cellsSlice.ts b/src/app/store/slices/cellsSlice.ts index 8e5ef33b..f00eda6c 100644 --- a/src/app/store/slices/cellsSlice.ts +++ b/src/app/store/slices/cellsSlice.ts @@ -335,6 +335,8 @@ return newCell if (!cell) return _rawTableDataStore.delete(cell._id) + // TODO: wrap in produce() once cell references are stored immutably + // (currently direct mutation + setAutoFreeze(false) pattern is used project-wide) cell._results = null cell._resultInfo = null diff --git a/src/app/store/slices/executionSlice.ts b/src/app/store/slices/executionSlice.ts index abba69f8..78a5a752 100644 --- a/src/app/store/slices/executionSlice.ts +++ b/src/app/store/slices/executionSlice.ts @@ -803,7 +803,7 @@ export const createExecutionSlice = (set: any, get: any) => ({ async executePivotCell(cell) { const selectedTable = cell.json?.selectedTable if (selectedTable) { - const tables = (get() as any)._duckdbTables || {} + const tables = get()._duckdbTables || {} if (tables[selectedTable]) { cell._resultInfo = `Table: ${selectedTable} — ${tables[selectedTable].columns?.length ?? 0} colonnes` } else { diff --git a/src/app/store/slices/exportSlice.ts b/src/app/store/slices/exportSlice.ts index 92793ede..12d3787b 100644 --- a/src/app/store/slices/exportSlice.ts +++ b/src/app/store/slices/exportSlice.ts @@ -50,7 +50,7 @@ export const createExportSlice = (set: any, get: any) => ({ } for (const cell of allCells) { if (cell.type === 'univerSheet' && cell._univerModified && cell._univerAPI) { - await (get() as any).captureUniverSnapshot(cell) + await get().captureUniverSnapshot(cell) } } } catch (e) { diff --git a/src/app/store/types.ts b/src/app/store/types.ts new file mode 100644 index 00000000..29e00df4 --- /dev/null +++ b/src/app/store/types.ts @@ -0,0 +1,523 @@ +/** + * types.ts — Types partagés pour le store Zustand sqljob. + * + * NotebookStoreState est l'interface complète du store, union de tous les slices. + * Les types NotebookCell, NotebookGroup, NotebookPage modélisent les entités métier. + */ + +import type { PivotConfig, PivotField } from '@sqlrooms/pivot' + +// ─── Entités métier ──────────────────────────────────────────────────────────── + +/** Informations sur une table DuckDB (DataSourcesPanel, PivotBody) */ +export type DuckdbTableInfo = { + rowCount: number + columns: PivotField[] + schema?: string +} + +/** Requête associée à une cellule (SQL, moteur, affichage) */ +export type CellQuery = { + name: string + sql: string + engine: string + showQueryEditor?: boolean + showQueryResult?: boolean + ast?: Record + degraded?: boolean + manualSql?: string +} + +/** Option d'un paramètre dropdown */ +export type DropdownOption = { + value: string + label: string +} + +/** Cellule notebook — propriétés persistées + propriétés runtime (préfixées _) */ +export type NotebookCell = { + _id: string + type: string + name?: string + title?: string + buttonLabel?: string + queries?: CellQuery[] + json?: Record + materialized?: string + maxRows?: number + paramType?: string + inputType?: string + rangeMin?: number + rangeMax?: number + rangeStep?: number + userVisible?: boolean + userEditable?: boolean + preserveUserValue?: boolean + referenceName?: string + childGroupId?: string + docxTemplateBase64?: string | null + docxTemplateFileName?: string + snapshot?: string + readOnly?: boolean + icon?: string + subtitle?: string + + // Runtime (mutable, non persisté) + _status?: string | null + _results?: Record[] | null + _resultInfo?: string | null + _rev?: number + _markdownContent?: string + _htmlContent?: string + _statValue?: string + _echartsOption?: unknown + _kpiHtml?: string | null + _kpiLabel?: string | null + _sublabel?: string | null + _kpiIcon?: string | null + _columnTypes?: Record + _schemaTypes?: Record + _value?: unknown + _options?: DropdownOption[] + _initialized?: boolean + _userModified?: boolean + _paramError?: string | null + _fileName?: string + _currentFile?: File | null + _isDragging?: boolean + _loaded?: boolean + _importFailed?: boolean + _loadedViaFallback?: boolean + _mainQueryError?: string | null + _fallbackQueryError?: string | null + _rejectErrorsCount?: number + _rejectedCellsCount?: number + _rowCount?: number + _queryBuilder?: string | null + _pendingFileLoad?: boolean + _perspectiveReady?: boolean + _perspectiveScheduled?: boolean + _perspectiveRendering?: boolean + _perspectiveWorker?: unknown + _perspectiveTable?: unknown + _perspectiveQuery?: string + _arrowTable?: unknown + _univerReady?: boolean + _univerRunId?: number + _univerRows?: Record[] | null + _univerCellTypes?: number[] | null + _univerColumnFormats?: (string | null)[] | null + _univerSnapshotPending?: string | null + _univerModified?: boolean + _univerAPI?: unknown + _easyMDEcli?: unknown + _easyMDE?: unknown + _renderedHtml?: string + _order?: number + [key: string]: unknown +} + +/** Configuration de boucle d'un groupe */ +export type GroupLoop = { + enabled: boolean + query: string + zip: boolean + zipQuery: string +} + +/** Groupe de cellules */ +export type NotebookGroup = { + _id: string + _type?: string + direction: 'row' | 'column' + style?: string + _order?: number + cells: NotebookCell[] + children?: NotebookGroup[] + loop?: GroupLoop + accordion?: boolean + title?: string + accordionOpen?: boolean + tabsChild?: boolean + name?: string + queries?: CellQuery[] + _ifQueryResult?: boolean | null + [key: string]: unknown +} + +/** Page du notebook */ +export type NotebookPage = { + _id: string + name: string + groups: NotebookGroup[] + linkGroups: NotebookGroup[] +} + +/** Fichier chargé (dropzone ou cellule source) */ +export type RoomFile = { + name: string + tableName: string + size: number + source: 'dropzone' | 'source-cell' +} + +/** Type d'une cellule (icône + label) */ +export type CellTypeDescriptor = { + type: string + label: string + icon: string +} + +/** Modal d'export */ +export type ExportModal = { + show: boolean + type: string + fileName: string + description: string + devMode: boolean | null + showLayout: boolean | null + encryptGist: boolean + gistPassphrase: string + includeFiles?: boolean +} + +// ─── Interface complète du store ─────────────────────────────────────────────── + +/** + * NotebookStoreState — état complet du store Zustand sqljob. + * + * Union des slices sqlrooms (roomShell, sqlEditor, cells, notebook, canvas) + * et des 9 slices Zustand purs (pages, helpers, parameters, export, groups, + * cells, files, execution, copyPaste), plus l'état initial buildInitialState(). + * + * Les méthodes des slices sont déclarées ici avec leur signature minimale. + * Un TODO reste en place pour les méthodes dont la signature exacte serait + * trop complexe à typer sans réécriture du business logic. + */ +export interface NotebookStoreState { + // ── État initial (buildInitialState) ────────────────────────────────────── + pages: NotebookPage[] + activePageIndex: number + isLoading: boolean + status: string + statusType: string + devMode: boolean + showLayout: boolean + availableThemes: string[] + currentTheme: string + dbEngine: string + showDbEngineModal: boolean + directedAcyclicGraph: boolean + + // GitHub Gist + githubToken: string + gistShareUrl: string + showGistModal: boolean + gistWasEncrypted: boolean + gistPassphraseToShare: string + showGistTokenModal: boolean + showJsonPassphraseModal: boolean + jsonPassphrase: string + jsonPassphraseError: string + jsonPassphraseLoading: boolean + _pendingEncryptedJson: unknown + + // Export modal + exportModal: ExportModal + + // DAG + _dagDebounceTimer: ReturnType | null + _dagDebounceDelay: number + _pagesInitialized: Set + + // Drag & drop pages + draggedPageIndex: number | null + dragOverPageIndex: number | null + + // Modals + showAddGroupModal: boolean + addCellToGroupModal: { open: boolean; path: number[] | null; groupIndex?: number | null } + insertGroupModal: { open: boolean; atIndex: number | null } + insertCellModal: { open: boolean; groupIndex?: number | null; atCellIndex: number | null; path?: number[] | null } + cellConfigModal: { open: boolean; path: number[] | null; cellIndex: number | null } + childGroupModal: { open: boolean; path: number[] | null; cellIndex: number | null; group: NotebookGroup | null } + loopConfigModal: { open: boolean; path: number[] | null } + groupSettingsModal: { open: boolean; path: number[] | null } + exportDropdownOpen: boolean + + // Loop + _currentLoopValue: unknown + _zipFiles: { filename: string; content: unknown; type: string }[] + _zipMode: boolean + + // Drag & drop cells/groups + draggedCellPath: number[] | null + dragOverCellPath: number[] | null + dragOverGroup: unknown + draggedChildPath: unknown + dragOverChildPath: unknown + draggedTopGroup: unknown + + // Cell types registry + cellTypes: CellTypeDescriptor[] + + // Runtime tables + _tables: Record + _duckdbTables: Record + _roomFiles: RoomFile[] + _rev: number + + // Clipboard + _clipboardItem: { type: 'sqljob-cell' | 'sqljob-group'; data: unknown } | null + + // ── Overrides / méthodes store ──────────────────────────────────────────── + addRoomFile: (file: File, tableName: string) => Promise + closeCellConfig: () => void + forceUpdate: () => void + initFromConfig: (loadedConfig: unknown) => void + setPages: (pages: NotebookPage[]) => void + setActivePageIndex: (i: number) => void + setIsLoading: (v: boolean) => void + setDevMode: (v: boolean) => void + setShowLayout: (v: boolean) => void + getActivePage: () => NotebookPage + getGroups: () => NotebookGroup[] + getLinkGroups: () => NotebookGroup[] + + // ── db (sqlrooms DuckDb connector) ──────────────────────────────────────── + db: { + schemaTrees: unknown[] + refreshTableSchemas: () => Promise + [key: string]: unknown + } + + // ── layout (sqlrooms RoomShell) ─────────────────────────────────────────── + layout: { + config: { nodes: unknown } + togglePanel: (panel: string, show?: boolean) => void + [key: string]: unknown + } + + // ── room (sqlrooms) ─────────────────────────────────────────────────────── + // TODO: type this properly — room comes from sqlrooms BaseRoomStoreState + room?: { initialized: boolean; initialize: () => Promise; destroy: () => Promise; captureException: (exception: unknown, captureContext?: unknown) => void; [key: string]: unknown } + + // ── Slices Zustand purs — méthodes ─────────────────────────────────────── + + // pagesSlice + addPage: () => void + deletePage: (index: number) => Promise + startPageDrag: (index: number, event: { dataTransfer: DataTransfer; [key: string]: unknown }) => void + onPageDragOver: (index: number, event: { preventDefault: () => void; [key: string]: unknown }) => void + onPageDragLeave: () => void + onPageDrop: (targetIndex: number, event: { preventDefault: () => void; [key: string]: unknown }) => void + endPageDrag: () => void + switchPage: (index: number) => void + activatePage: (index: number) => Promise + refreshMarkdownCellsForPage: (pageIndex: number) => void + shouldShowCell: (cell: NotebookCell) => boolean + shouldShowGroup: (group: NotebookGroup) => boolean + + // helpersSlice + hasSourceCells: () => boolean + canUseDucklings: () => boolean + switchDbEngine: (newEngine: string) => Promise + refreshDuckdbTables: () => Promise + refreshDuckdbSchema: () => Promise + init: () => Promise + evaluateGroupIfQuery: (group: NotebookGroup) => Promise + evaluateAllGroupIfQueries: () => Promise + setStatus: (message: string, type: string) => void + syncMarkdownToEditor: (path: number[], cellIndex: number) => void + getCellIcon: (type: string) => string + generateCellId: () => string + generateGroupId: () => string + generatePageId: () => string + isNameUniqueAcrossPages: (name: string, type: string, excludePageIndex?: number | null, excludePath?: unknown, excludeCellIndex?: number | null) => boolean + getAllNamesOfType: (type: string) => string[] + getCell: (groupIndex: number, cellIndex: number) => NotebookCell | undefined + downloadSourceFile: (pathOrIndex: number | number[], cellIndex: number) => void + + // parametersSlice + getParameters: () => Record + parseQueryWithParameters: (query: string, extraParams?: Record) => string + findReferencedParams: (query: string) => string[] + findDependentCells: (paramName: string) => { cell: NotebookCell; path: number[]; cellIndex: number }[] + findDependentGroups: (paramName: string) => { group: NotebookGroup; path: number[] }[] + detectCycleInDAG: () => boolean + onParameterValueChange: (cell: NotebookCell) => Promise + _executeDAGRefresh: (paramName: string) => Promise + generateUniqueParamName: () => string + isParamNameUsed: (paramName: string, excludeId: string) => boolean + validateParamName: (pathOrIndex: unknown, cellIndex: number) => void + + // exportSlice + setTheme: (themeName: string) => void + buildExportConfig: (options?: { devMode?: boolean; showLayout?: boolean; includeFileData?: boolean }) => Promise + openExportModal: (type: string) => void + executeExport: () => Promise + exportHTMLWithConfig: (config: unknown, fileName?: string, passphrase?: string | null, includeFiles?: boolean) => Promise + copyExportJson: () => Promise + cancelExport: () => void + saveGithubToken: () => void + cancelGithubToken: () => void + copyGistUrl: () => void + copyGistPassphrase: () => void + closeGistModal: () => void + openGistUrl: () => void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loadConfig: (event: any) => Promise + cancelJsonPassphraseModal: () => void + unlockJsonConfig: () => Promise + applyImportedConfig: (config: unknown) => Promise + + // groupsSlice + getFlattenedGroups: () => unknown[] + getFlattenedGroupsForAllPages: () => unknown[] + getGroupItems: (group: NotebookGroup) => unknown[] + getGroupAtPath: (path: number[]) => NotebookGroup | null + getParentGroup: (path: number[]) => NotebookGroup | null + getCellAtPath: (path: number[], cellIndex: number) => NotebookCell | undefined + createNewGroup: (direction?: string) => NotebookGroup + addNestedGroup: (path: number[]) => void + toggleGroupDirection: (path: number[]) => void + openLoopConfigModal: (path: number[]) => void + openGroupSettingsModal: (path: number[]) => void + testGroupIfQuery: (path: number[]) => Promise + toggleAccordion: (path: number[]) => void + getDefaultLoopQuery: () => string + getDefaultZipQuery: () => string + deleteGroupAtPath: (path: number[]) => Promise + getLinkGroupById: (groupId: string) => NotebookGroup | undefined + openChildGroupModal: (path: number[], cellIndex: number) => Promise + closeChildGroupModal: () => void + deleteChildGroupModal: () => Promise + moveGroupAtPath: (path: number[], direction: number) => void + moveCellInGroupAtPath: (path: number[], cellIndex: number, direction: number) => void + getGroupElementId: (path: number[]) => string + openAddGroupModal: () => void + getNextOrder: (group: NotebookGroup) => number + getSortedCells: (group: NotebookGroup) => { cell: NotebookCell; originalIndex: number }[] + getSortedChildren: (group: NotebookGroup) => { child: NotebookGroup; originalIndex: number }[] + getAllItemsSorted: (group: NotebookGroup) => { type: string; item: NotebookCell | NotebookGroup; originalIndex: number; order: number }[] + getTabName: (tabItem: unknown, tabIdx: number) => string + moveItemInGroup: (path: number[], itemType: string, originalIndex: number, direction: number) => void + isFirstInGroup: (group: NotebookGroup, itemType: string, originalIndex: number) => boolean + isLastInGroup: (group: NotebookGroup, itemType: string, originalIndex: number) => boolean + getSortedIndex: (group: NotebookGroup, itemType: string, originalIndex: number) => number + + // cellsSlice + hasCellMinSize: (cell: NotebookCell) => boolean + hasCellMaxSize: (cell: NotebookCell) => boolean + hasCellHeight: (cell: NotebookCell) => boolean + isSqlCellWithEditor: (type: string) => boolean + bodyDisplayShouldShowSkeleton: (cell: NotebookCell) => boolean + bodyDisplayShouldShowContent: (cell: NotebookCell) => boolean + getCellHeightVars: (cell: NotebookCell) => string + getCellSizeOuterClass: (cell: NotebookCell, isColumn: boolean) => string + getCellWrapperStyle: (cell: NotebookCell, isColumn: boolean, order: number) => Record + getCellSizeInnerClass: () => string + createNewCell: (type: string) => NotebookCell + addGroup: (cellType: string) => void + addCellToGroup: (pathOrIndex: number | number[], cellType: string) => void + openAddCellToGroupModal: (pathOrIndex: number | number[]) => void + openInsertGroupModal: (atIndex: number) => void + insertGroupAt: (atIndex: number, cellType: string) => void + openInsertCellModal: (pathOrIndex: number | number[], atCellIndex: number) => void + insertCellAt: (pathOrIndex: number | number[], atCellIndex: number, cellType: string) => void + deleteGroup: (pathOrIndex: number | number[]) => void + moveGroup: (pathOrIndex: number | number[], direction: number) => void + deleteCellAt: (pathOrIndex: number | number[], cellIndex: number) => Promise + moveCellInGroup: (pathOrIndex: number | number[], cellIndex: number, direction: number) => void + ensureCellName: (pathOrIndex: number | number[], cellIndex: number) => void + ensureAllCellsHaveNames: () => void + openCellConfig: (pathOrIndex: number | number[], cellIndex: number) => void + getCommonParamsForType: (type: string) => unknown[] + getCommonParamsExcludingName: (type: string) => unknown[] + getCommonParamDef: (paramKey: string, type: string) => unknown + getSpecificParamsForType: (type: string) => unknown[] + isSpecificParamVisible: (param: unknown, cell: NotebookCell) => boolean + getQueryLabelForType: (type: string, indexOrName: string | number) => string + getQueryCountForType: (type: string) => number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getCellValueByPath: (cell: NotebookCell, path: string) => any + setCellValueByPath: (cell: NotebookCell, path: string, value: unknown) => void + onCellTypeChange: (pathOrIndex: number | number[], cellIndex: number, oldType: string) => void + generateUniqueCellName: (type: string, excludeId?: string | null) => string + isCellNameUsed: (name: string, excludeId?: string | null) => boolean + generateUniqueSourceName: () => string + validateCellName: (path: number[], cellIndex: number) => void + validateSingleSourceName: (pathOrIndex: number | number[], cellIndex: number) => void + + // filesSlice + loadEmbeddedFiles: () => Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handleSingleSourceDrop: (e: any, path: number[], cellIndex: number) => void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handleSingleSourceFileSelect: (e: any, path: number[], cellIndex: number) => void + loadSingleSourceFile: (file: File, path: number[], cellIndex: number, options?: Record) => Promise + executeSourceCell: (cell: NotebookCell, path: number[], cellIndex: number) => Promise + cleanupSourceCell: (cell: NotebookCell) => Promise + removeSingleSourceFile: (path: number[], cellIndex: number) => Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handleDocxTemplateDrop: (e: any, path: number[], cellIndex: number) => void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handleDocxTemplateFileSelect: (e: any, path: number[], cellIndex: number) => void + loadDocxTemplate: (file: File, path: number[], cellIndex: number) => Promise + downloadDocxTemplate: (path: number[], cellIndex: number) => void + removeDocxTemplate: (path: number[], cellIndex: number) => void + loadPendingSourceFiles: () => Promise + + // executionSlice + runGroupAtPath: (path: number[]) => Promise<{ stopped: boolean; reason?: string; cellName?: string }> + isCellSkippedInAutoFlow: (cell: NotebookCell) => boolean + runGroupOnce: (path: number[], group: NotebookGroup) => Promise<{ stopped: boolean }> + addFileToZip: (filename: string, content: unknown, type?: string) => boolean + downloadOrZipFile: (filename: string, content: unknown, mimeType?: string) => boolean + runGroupWithLoop: (path: number[], group: NotebookGroup) => Promise<{ stopped: boolean }> + generateAndDownloadZip: (group: NotebookGroup) => Promise + runCellAt: (pathOrIndex: number | number[], cellIndex: number) => Promise + runGroup: (pathOrIndex: number | number[]) => Promise<{ stopped: boolean }> + executeSqlRecursiveParseCell: (cell: NotebookCell) => Promise + showSqlEditorVisible: (cell: NotebookCell) => boolean + showQueryResult: (cell: NotebookCell) => boolean + isSqlResultTabular: (cell: NotebookCell) => boolean + isSqlResultText: (cell: NotebookCell) => boolean + getSqlResultAsText: (cell: NotebookCell) => string + executeMarkdownCell: (cell: NotebookCell) => Promise + executeIframeCell: (cell: NotebookCell) => Promise + renderIframeInContainer: (cell: NotebookCell) => void + executeSqlStatCell: (cell: NotebookCell) => Promise + executePivotCell: (cell: NotebookCell) => Promise + executeUiParameterCell: (cell: NotebookCell) => Promise + executePublipostageWordCell: (cell: NotebookCell) => Promise + executePdfmeCell: (cell: NotebookCell) => Promise + executePerspectiveCell: (cell: NotebookCell) => Promise + renderPerspectiveInContainer: (cell: NotebookCell) => Promise + executeUniverSheetCell: (cell: NotebookCell) => Promise + captureUniverSnapshot: (cell: NotebookCell, univerAPI: unknown) => Promise + exportUniverToXlsx: (univerAPI: unknown, cellName: string) => Promise + runGroupsFromIndex: (startGroupIndex: number) => Promise + runGroupsFromIndexWithStopConditions: (startGroupIndex: number) => Promise<{ stopped: boolean }> + runGroupWithStopConditions: (path: number[]) => Promise<{ stopped: boolean; reason?: string }> + runCellsAfterWithStopConditions: (path: number[], cellIndex: number, cellId?: string | null) => Promise<{ stopped: boolean }> + runCellsAfter: (path: number[], cellIndex: number) => Promise + runAllGroups: () => Promise + + // copyPasteSlice + hasClipboardItem: () => boolean + _safeSerialize: (obj: unknown) => unknown + _cloneCellForCopy: (cell: NotebookCell) => Partial + _cloneGroupForCopy: (group: NotebookGroup) => Partial + _collectUsedCellNames: () => Set + _makeUniqueName: (baseName: string, usedNames: Set) => string + _prepareGroupForPaste: (group: NotebookGroup, usedNames: Set) => void + copyCellAt: (pathOrIndex: number | number[], cellIndex: number) => void + copyGroupAtPath: (pathOrIndex: number | number[]) => void + pasteToGroup: (pathOrIndex: number | number[]) => void + + // ── Index signature pour compatibilité avec les méthodes sqlrooms ───────── + // (sqlEditor, roomShell, cells, notebook, canvas ont leurs propres types + // non exportés publiquement — on les laisse en unknown pour éviter any) + [key: string]: unknown +} From 1f1026bbc624ac8fd1afc212d26a8f8d78e2f333 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 12:54:29 +0000 Subject: [PATCH 20/20] refactor: align with sqlrooms examples (Zod panels, intersection types, typed slices) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PanelTypes Zod enum (z.enum) for panel IDs — matches notebook/mosaic examples - Add `satisfies LayoutConfig` to layout config object — type-checks config shape - Replace 523-line monolithic NotebookStoreState with intersection type: RoomShellSliceState & SqlEditorSliceState & CellsSliceState & NotebookSliceState & CanvasSliceState & CustomNotebookState - createRoomStore now properly typed — no more createRoomStore - Remove double as-unknown-as casts from roomStore/useNotebookStore exports - Remove `roomStore as any` cast in room.tsx RoomShell prop - Update all 9 custom slice signatures from (set: any, get: any) to (set: StoreApi['setState'], get: StoreApi['getState']) - Fix type errors surfaced by proper typing (clone casts, as const literals, DragEvent types, direction literal type, cell mutations) - Document setAutoFreeze(false) as TODO(produce-migration) https://claude.ai/code/session_01AzBiSv8y9zB8Wfdyxwr1YP --- src/app/room.tsx | 3 +- src/app/store/notebookStore.ts | 52 ++++++++--------- src/app/store/slices/cellsSlice.ts | 9 ++- src/app/store/slices/copyPasteSlice.ts | 19 ++++--- src/app/store/slices/executionSlice.ts | 25 +++++---- src/app/store/slices/exportSlice.ts | 9 ++- src/app/store/slices/filesSlice.ts | 9 ++- src/app/store/slices/groupsSlice.ts | 11 +++- src/app/store/slices/helpersSlice.ts | 11 +++- src/app/store/slices/pagesSlice.ts | 7 ++- src/app/store/slices/parametersSlice.ts | 7 ++- src/app/store/types.ts | 74 ++++++++++++------------- 12 files changed, 136 insertions(+), 100 deletions(-) diff --git a/src/app/room.tsx b/src/app/room.tsx index 91fac74e..ebfd45b3 100644 --- a/src/app/room.tsx +++ b/src/app/room.tsx @@ -118,8 +118,7 @@ export function Room() { return ( <> - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - + diff --git a/src/app/store/notebookStore.ts b/src/app/store/notebookStore.ts index 4ea1d9df..a28621d5 100644 --- a/src/app/store/notebookStore.ts +++ b/src/app/store/notebookStore.ts @@ -6,6 +6,7 @@ * createRoomShellSlice ajoute le système de layout mosaic (RoomShell). */ import { setAutoFreeze } from 'immer' +import { z } from 'zod' import { createRoomShellSlice, createRoomStore, persistSliceConfigs, LayoutConfig } from '@sqlrooms/room-shell' import type { NotebookStoreState } from './types' import { createBaseDuckDbConnector } from '@sqlrooms/duckdb-core' @@ -41,9 +42,10 @@ import { CELL_TYPE_SCHEMAS, CELL_TYPE_HANDLERS } from '../../lib/cellTypeSchemas import { formatValueForInputType } from '../../lib/utils' -// Les mixins Alpine mutent directement les tableaux du state (this.groups.push(...)). -// @sqlrooms/duckdb utilise Immer en interne qui freeze le state après chaque produce(). -// setAutoFreeze(false) empêche ce freeze pour que les mutations des mixins fonctionnent. +// TODO(produce-migration): setAutoFreeze(false) est un workaround pour permettre les +// mutations directes sur les cellules (cell._status = 'running', cell._results = rows…) +// dans executionSlice et les autres slices. La migration vers produce() dans chaque slice +// permettrait de supprimer cette ligne, mais requiert une refonte architecturale complète. setAutoFreeze(false) // ─── Expose globals (nécessaire pour les expressions dans les templates HTML) ─ @@ -194,7 +196,7 @@ function buildInitialState() { // DAG _dagDebounceTimer: null, _dagDebounceDelay: 200, - _pagesInitialized: new Set(), + _pagesInitialized: new Set(), // Drag & drop pages draggedPageIndex: null, @@ -248,6 +250,10 @@ function buildInitialState() { } } +// ─── Panel IDs (Zod enum — typage statique + sécurité à l'exécution) ───────── +export const PanelTypes = z.enum(['main', 'data'] as const) +export type PanelTypes = z.infer + // ─── Connecteur DuckDB ponté vers DuckDBManager ─────────────────────────────── // Permet à SqlEditorModal (et state.db) d'utiliser la même instance DuckDB // que les cells sqljob, sans dupliquer la connexion. @@ -276,11 +282,7 @@ const duckdbManagerConnector = createBaseDuckDbConnector( ) // ─── Store Zustand ──────────────────────────────────────────────────────────── -// createRoomStore is required because the sqlrooms internal slices -// (createSqlEditorSlice, createRoomShellSlice, etc.) each expect their own -// slice-specific set/get types which cannot be unified without a full rewrite. -// The public API is typed via the typed re-export of useNotebookStore below. -const { roomStore: _roomStore, useRoomStore: _useNotebookStore } = createRoomStore( +const { roomStore: _roomStore, useRoomStore: _useNotebookStore } = createRoomStore( persistSliceConfigs( { name: 'sqljob-layout-state-v1', @@ -297,16 +299,16 @@ const { roomStore: _roomStore, useRoomStore: _useNotebookStore } = createRoomSto layout: { config: { type: 'mosaic', - nodes: 'main', - }, + nodes: PanelTypes.enum.main, + } satisfies LayoutConfig, panels: { - main: { + [PanelTypes.enum.main]: { title: 'Notebook', icon: () => null, component: NotebookPanel, placement: 'main', }, - data: { + [PanelTypes.enum.data]: { title: 'Sources', icon: DatabaseIcon, component: DataSourcesPanel, @@ -348,14 +350,12 @@ const { roomStore: _roomStore, useRoomStore: _useNotebookStore } = createRoomSto const originalTogglePanel = roomShellState.layout.togglePanel const mobileTogglePanel = (panel: string, show?: boolean) => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 - if (isMobile && panel === 'data') { + if (isMobile && panel === PanelTypes.enum.data) { const dataVisible = isDataPanelVisible() if (dataVisible) { - // Fermer → retour au notebook - set((s: any) => ({ layout: { ...s.layout, config: { ...s.layout.config, nodes: 'main' } } })) + set((s: any) => ({ layout: { ...s.layout, config: { ...s.layout.config, nodes: PanelTypes.enum.main } } })) } else { - // Ouvrir → plein écran data - set((s: any) => ({ layout: { ...s.layout, config: { ...s.layout.config, nodes: 'data' } } })) + set((s: any) => ({ layout: { ...s.layout, config: { ...s.layout.config, nodes: PanelTypes.enum.data } } })) } return } @@ -431,7 +431,7 @@ const { roomStore: _roomStore, useRoomStore: _useNotebookStore } = createRoomSto window._loadedConfig = loadedConfig } const newState = buildInitialState() - set({ ...newState }) + set(newState as Partial) }, // Setters directs pour le state exposé aux composants React @@ -458,14 +458,6 @@ const { roomStore: _roomStore, useRoomStore: _useNotebookStore } = createRoomSto }) ) -// ─── Typed public API ───────────────────────────────────────────────────────── -// The internal store is createRoomStore (required by sqlrooms slice type -// constraints). We re-export a typed wrapper so selectors in components can use -// (s: NotebookStoreState) instead of (s: any). -import type { StoreApi, UseBoundStore } from 'zustand' - -export const roomStore = _roomStore as unknown as StoreApi - -// Cast the hook to the typed state so all selector callbacks receive -// NotebookStoreState instead of any. -export const useNotebookStore = _useNotebookStore as unknown as UseBoundStore> +// ─── Exports publics ────────────────────────────────────────────────────────── +export const roomStore = _roomStore +export const useNotebookStore = _useNotebookStore diff --git a/src/app/store/slices/cellsSlice.ts b/src/app/store/slices/cellsSlice.ts index f00eda6c..c5ebf6f7 100644 --- a/src/app/store/slices/cellsSlice.ts +++ b/src/app/store/slices/cellsSlice.ts @@ -1,10 +1,15 @@ +import type { StoreApi } from 'zustand' import { rawTableDataStore as _rawTableDataStore } from '../../../lib/tableDataStore' import { ConfigManager } from '../../../lib/ConfigManager' import { CellConfigService, initializeCell } from '../../../lib/CellConfigService' import { CELL_TYPE_SCHEMAS } from '../../../lib/cellTypeSchemas' import { useConfirmModal } from '../uiStores' +import type { NotebookStoreState } from '../types' -export const createCellsSlice = (set: any, get: any) => ({ +export const createCellsSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ hasCellMinSize(cell: any) { const v = (x: any) => (x !== undefined && x !== null && String(x).trim() !== '') @@ -247,7 +252,7 @@ return newCell _rawTableDataStore.delete(cell._id) const { _tables } = get() if (_tables && _tables[cell._id]) { - _tables[cell._id].destroy() + (_tables[cell._id] as any).destroy() delete _tables[cell._id] } group.cells.splice(cellIndex, 1) diff --git a/src/app/store/slices/copyPasteSlice.ts b/src/app/store/slices/copyPasteSlice.ts index 75820a9f..db4532d0 100644 --- a/src/app/store/slices/copyPasteSlice.ts +++ b/src/app/store/slices/copyPasteSlice.ts @@ -1,5 +1,10 @@ +import type { StoreApi } from 'zustand' +import type { NotebookStoreState } from '../types' -export const createCopyPasteSlice = (set: any, get: any) => ({ +export const createCopyPasteSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ /** Item courant dans le presse-papier interne (cell ou groupe) */ _clipboardItem: null as { type: 'sqljob-cell' | 'sqljob-group'; data: any } | null, @@ -35,7 +40,7 @@ export const createCopyPasteSlice = (set: any, get: any) => ({ /** Clone une cell en supprimant les props runtime */ _cloneCellForCopy(cell: any) { - const clone = get()._safeSerialize(cell) + const clone = get()._safeSerialize(cell) as Record if (!clone) return {} // Props runtime à supprimer delete clone._id @@ -60,11 +65,11 @@ export const createCopyPasteSlice = (set: any, get: any) => ({ /** Clone un groupe en supprimant les props runtime (récursif) */ _cloneGroupForCopy(group: any) { - const clone = get()._safeSerialize(group) + const clone = get()._safeSerialize(group) as Record if (!clone) return {} delete clone._id - clone.cells = (clone.cells || []).map((c: any) => get()._cloneCellForCopy(c)) - clone.children = (clone.children || []).map((child: any) => get()._cloneGroupForCopy(child)) + clone.cells = ((clone.cells as any[]) || []).map((c: any) => get()._cloneCellForCopy(c)) + clone.children = ((clone.children as any[]) || []).map((child: any) => get()._cloneGroupForCopy(child)) return clone }, @@ -119,7 +124,7 @@ export const createCopyPasteSlice = (set: any, get: any) => ({ const cell = get().getCellAtPath(path, cellIndex) if (!cell) return const clone = get()._cloneCellForCopy(cell) - const item = { type: 'sqljob-cell', data: clone } + const item = { type: 'sqljob-cell' as const, data: clone } set({ _clipboardItem: item }) try { navigator.clipboard.writeText(JSON.stringify(item)) } catch {} get().setStatus('Cellule copiée', 'success') @@ -131,7 +136,7 @@ export const createCopyPasteSlice = (set: any, get: any) => ({ const group = get().getGroupAtPath(path) if (!group) return const clone = get()._cloneGroupForCopy(group) - const item = { type: 'sqljob-group', data: clone } + const item = { type: 'sqljob-group' as const, data: clone } set({ _clipboardItem: item }) try { navigator.clipboard.writeText(JSON.stringify(item)) } catch {} get().setStatus('Groupe copié', 'success') diff --git a/src/app/store/slices/executionSlice.ts b/src/app/store/slices/executionSlice.ts index 78a5a752..41aa3772 100644 --- a/src/app/store/slices/executionSlice.ts +++ b/src/app/store/slices/executionSlice.ts @@ -1,3 +1,4 @@ +import type { StoreApi } from 'zustand' import { safeEvalJs } from '../../../lib/safeEval' import { rawTableDataStore as _rawTableDataStore } from '../../../lib/tableDataStore' import { DuckDBManager } from '../../../lib/DuckDBManager' @@ -7,6 +8,7 @@ import { CELL_TYPE_SCHEMAS } from '../../../lib/cellTypeSchemas' import { EChartSqlParser } from '../../../lib/EChartSqlParser' import { formatValueForInputType } from '../../../lib/utils' import { FileHandler } from '../../../lib/FileHandler' +import type { NotebookStoreState } from '../types' // PizZip est chargé dynamiquement via CDNManager.loadPizZip() et exposé sur window declare const PizZip: any; @@ -120,7 +122,10 @@ function _arrowTableToUniverRows(table: any): { rows: any[]; cellTypes: number[] return { rows, cellTypes, columnFormats } } -export const createExecutionSlice = (set: any, get: any) => ({ +export const createExecutionSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ async runGroupAtPath(path) { const group = get().getGroupAtPath(path) @@ -163,7 +168,7 @@ export const createExecutionSlice = (set: any, get: any) => ({ continue } - const cell = item.item + const cell = item.item as import('../types').NotebookCell if (cell.type === 'buttonRunNextCells') { get().setStatus('Arrêt : bouton "Exécuter les cellules suivantes" rencontré', 'info') return { stopped: true, reason: 'buttonRunNextCells' } @@ -260,7 +265,7 @@ export const createExecutionSlice = (set: any, get: any) => ({ continue } - const cell = item.item + const cell = item.item as import('../types').NotebookCell if (cell.type === 'buttonRunNextCells') { get().setStatus('Arrêt : bouton "Exécuter les cellules suivantes" rencontré', 'info') return { stopped: true, reason: 'buttonRunNextCells' } @@ -362,8 +367,8 @@ export const createExecutionSlice = (set: any, get: any) => ({ try { const schema = CELL_TYPE_SCHEMAS?.types[cell?.type] const handler = schema?.executeHandler - if (handler && typeof get()[handler] === 'function') { - await get()[handler](cell, path, cellIndex) + if (handler && typeof (get() as any)[handler] === 'function') { + await (get() as any)[handler](cell, path, cellIndex) } cell._status = 'success' @@ -988,7 +993,7 @@ export const createExecutionSlice = (set: any, get: any) => ({ for (let i = 0; i < dataResults.length; i++) { const rowData = dataResults[i] const filenameRow = filenameResults[i] - const filename = Object.values(filenameRow)[0] || `document_${i + 1}.docx` + const filename = String(Object.values(filenameRow)[0] || `document_${i + 1}.docx`) let templateData = rowData @@ -1452,7 +1457,7 @@ export const createExecutionSlice = (set: any, get: any) => ({ continue } - const cell = item.item + const cell = item.item as import('../types').NotebookCell if (cell.type === 'buttonRunNextCells') { return { stopped: true, reason: 'buttonRunNextCells' } @@ -1508,7 +1513,7 @@ export const createExecutionSlice = (set: any, get: any) => ({ continue } - const cell = item.item + const cell = item.item as import('../types').NotebookCell if (cell.type === 'buttonRunNextCells') { get().setStatus('Arrêt : bouton "Exécuter les cellules suivantes" rencontré', 'info') @@ -1565,7 +1570,7 @@ export const createExecutionSlice = (set: any, get: any) => ({ continue } - const cell = item.item + const cell = item.item as import('../types').NotebookCell if (cell.type === 'buttonRunNextCells') { get().setStatus('Arrêt : bouton "Exécuter les cellules suivantes" rencontré', 'info') @@ -1629,7 +1634,7 @@ export const createExecutionSlice = (set: any, get: any) => ({ await get().runGroupAtPath([...path, item.originalIndex]) continue } - const cell = item.item + const cell = item.item as import('../types').NotebookCell if (cell?.type === 'buttonRunNextCells') break if (get().isCellSkippedInAutoFlow(cell)) continue await get().runCellAt(path, item.originalIndex) diff --git a/src/app/store/slices/exportSlice.ts b/src/app/store/slices/exportSlice.ts index 12d3787b..9ee66c1d 100644 --- a/src/app/store/slices/exportSlice.ts +++ b/src/app/store/slices/exportSlice.ts @@ -4,14 +4,19 @@ * * Changement clé : buildExportConfig() utilise get() directement → "config dans le store". */ +import type { StoreApi } from 'zustand' import { ConfigManager, exportConfigToJson } from '../../../lib/ConfigManager' import { GistEncrypt } from '../../../lib/GistEncrypt' import { GitHubGistManager } from '../../../lib/GitHubGistManager' import { FileHandler } from '../../../lib/FileHandler' import { initializeCell } from '../../../lib/CellConfigService' import { applyThemeFromConfig, STORAGE_LIGHT, STORAGE_DARK, STORAGE_PRESET } from '../../components/modals/ThemeCustomModal' +import type { NotebookStoreState } from '../types' -export const createExportSlice = (set: any, get: any) => ({ +export const createExportSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ setTheme(themeName: string) { const theme = themeName === 'dark' ? 'dark' : 'light' @@ -50,7 +55,7 @@ export const createExportSlice = (set: any, get: any) => ({ } for (const cell of allCells) { if (cell.type === 'univerSheet' && cell._univerModified && cell._univerAPI) { - await get().captureUniverSnapshot(cell) + await get().captureUniverSnapshot(cell, cell._univerAPI) } } } catch (e) { diff --git a/src/app/store/slices/filesSlice.ts b/src/app/store/slices/filesSlice.ts index aeaa9952..c6f61d69 100644 --- a/src/app/store/slices/filesSlice.ts +++ b/src/app/store/slices/filesSlice.ts @@ -1,8 +1,13 @@ +import type { StoreApi } from 'zustand' import { FileHandler } from '../../../lib/FileHandler' import { ConfigManager } from '../../../lib/ConfigManager' import { DuckDBManager } from '../../../lib/DuckDBManager' +import type { NotebookStoreState } from '../types' -export const createFilesSlice = (set: any, get: any) => ({ +export const createFilesSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ async loadEmbeddedFiles() { const sourceFileScripts = document.querySelectorAll('script[id^="sourceFile_"]') @@ -166,7 +171,7 @@ export const createFilesSlice = (set: any, get: any) => ({ if (logicalExt === 'xls') { get().setStatus(`Conversion Excel (.xls) via SheetJS...`, 'loading') - const xlsxConf = cell.json?.xlsx || {} + const xlsxConf = (cell.json?.xlsx || {}) as Record const { csv, csvFileName } = await FileHandler.processExcelFile(file, xlsxConf.options, xlsxConf.toCsvOptions, xlsxConf.sheetSelection) const csvBlob = new Blob([csv], { type: 'text/csv' }) await DuckDBManager.registerFile(csvFileName, csvBlob) diff --git a/src/app/store/slices/groupsSlice.ts b/src/app/store/slices/groupsSlice.ts index 31c14347..094f8d27 100644 --- a/src/app/store/slices/groupsSlice.ts +++ b/src/app/store/slices/groupsSlice.ts @@ -1,7 +1,12 @@ +import type { StoreApi } from 'zustand' import { ConfigManager } from '../../../lib/ConfigManager' import { useConfirmModal } from '../uiStores' +import type { NotebookStoreState } from '../types' -export const createGroupsSlice = (set: any, get: any) => ({ +export const createGroupsSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ getFlattenedGroups() { const result = [] @@ -99,7 +104,7 @@ export const createGroupsSlice = (set: any, get: any) => ({ return group?.cells?.[cellIndex] }, - createNewGroup(direction = 'row') { + createNewGroup(direction: 'row' | 'column' = 'row') { return { _id: get().generateGroupId(), direction, @@ -356,7 +361,7 @@ FROM source1 LIMIT 10;` moveItemInGroup(path: any, itemType: string, originalIndex: number, direction: number) { const activePage = get().getActivePage() const group = (!path || path.length === 0) - ? { children: activePage?.groups || [] } + ? { children: activePage?.groups || [] } as unknown as import('../types').NotebookGroup : get().getGroupAtPath(path) if (!group) return diff --git a/src/app/store/slices/helpersSlice.ts b/src/app/store/slices/helpersSlice.ts index bbc67d21..be0c13a3 100644 --- a/src/app/store/slices/helpersSlice.ts +++ b/src/app/store/slices/helpersSlice.ts @@ -2,12 +2,17 @@ * helpersSlice — utilitaires, initialisation, gestion moteur DB, statuts. * Converti de helpersMixin.ts (Alpine this-proxy) vers un slice Zustand pur. */ +import type { StoreApi } from 'zustand' import { DuckDBManager } from '../../../lib/DuckDBManager' import { ConfigManager } from '../../../lib/ConfigManager' import { safeEvalJs } from '../../../lib/safeEval' import { FileHandler } from '../../../lib/FileHandler' +import type { NotebookStoreState } from '../types' -export const createHelpersSlice = (set: any, get: any) => ({ +export const createHelpersSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ hasSourceCells() { const { pages } = get() @@ -181,9 +186,9 @@ export const createHelpersSlice = (set: any, get: any) => ({ if (!cell || !cell._easyMDE) return const engine = ConfigManager.getCellEngine(cell, 'main') if (engine === 'sql' || engine === 'js') return - const currentValue = cell._easyMDE.value() + const currentValue = (cell._easyMDE as any).value() const targetContent = ConfigManager.getCellEditableContent(cell) - if (currentValue !== targetContent) cell._easyMDE.value(targetContent) + if (currentValue !== targetContent) (cell._easyMDE as any).value(targetContent) }, getCellIcon(type: string) { diff --git a/src/app/store/slices/pagesSlice.ts b/src/app/store/slices/pagesSlice.ts index 5a4fd1ba..63b207a4 100644 --- a/src/app/store/slices/pagesSlice.ts +++ b/src/app/store/slices/pagesSlice.ts @@ -4,10 +4,15 @@ * Utilise get()/set() directement au lieu du proxy createThisProxy. */ import { produce } from 'immer' +import type { StoreApi } from 'zustand' import { ConfigManager } from '../../../lib/ConfigManager' import { useConfirmModal } from '../uiStores' +import type { NotebookStoreState } from '../types' -export const createPagesSlice = (set: any, get: any) => ({ +export const createPagesSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ addPage() { const pages = get().pages diff --git a/src/app/store/slices/parametersSlice.ts b/src/app/store/slices/parametersSlice.ts index 60d2f292..77743a2c 100644 --- a/src/app/store/slices/parametersSlice.ts +++ b/src/app/store/slices/parametersSlice.ts @@ -2,9 +2,14 @@ * parametersSlice — gestion des paramètres UI et du DAG (Directed Acyclic Graph). * Converti de parametersMixin.ts (Alpine this-proxy) vers un slice Zustand pur. */ +import type { StoreApi } from 'zustand' import { ConfigManager } from '../../../lib/ConfigManager' +import type { NotebookStoreState } from '../types' -export const createParametersSlice = (set: any, get: any) => ({ +export const createParametersSlice = ( + set: StoreApi['setState'], + get: StoreApi['getState'], +) => ({ getParameters() { const { _currentLoopValue } = get() diff --git a/src/app/store/types.ts b/src/app/store/types.ts index 29e00df4..6c309b46 100644 --- a/src/app/store/types.ts +++ b/src/app/store/types.ts @@ -1,11 +1,17 @@ /** * types.ts — Types partagés pour le store Zustand sqljob. * - * NotebookStoreState est l'interface complète du store, union de tous les slices. + * NotebookStoreState est composé par intersection des slice state types sqlrooms + * et de CustomNotebookState (état propre à sqljob). * Les types NotebookCell, NotebookGroup, NotebookPage modélisent les entités métier. */ import type { PivotConfig, PivotField } from '@sqlrooms/pivot' +import type { RoomShellSliceState } from '@sqlrooms/room-shell' +import type { SqlEditorSliceState } from '@sqlrooms/sql-editor' +import type { CellsSliceState } from '@sqlrooms/cells' +import type { NotebookSliceState } from '@sqlrooms/notebook' +import type { CanvasSliceState } from '@sqlrooms/canvas' // ─── Entités métier ──────────────────────────────────────────────────────────── @@ -181,20 +187,13 @@ export type ExportModal = { includeFiles?: boolean } -// ─── Interface complète du store ─────────────────────────────────────────────── +// ─── État custom sqljob (hors slices sqlrooms) ──────────────────────────────── /** - * NotebookStoreState — état complet du store Zustand sqljob. - * - * Union des slices sqlrooms (roomShell, sqlEditor, cells, notebook, canvas) - * et des 9 slices Zustand purs (pages, helpers, parameters, export, groups, - * cells, files, execution, copyPaste), plus l'état initial buildInitialState(). - * - * Les méthodes des slices sont déclarées ici avec leur signature minimale. - * Un TODO reste en place pour les méthodes dont la signature exacte serait - * trop complexe à typer sans réécriture du business logic. + * CustomNotebookState — propriétés propres à sqljob non couvertes par les slice + * state types sqlrooms (buildInitialState + 9 slices Zustand purs). */ -export interface NotebookStoreState { +export interface CustomNotebookState { // ── État initial (buildInitialState) ────────────────────────────────────── pages: NotebookPage[] activePageIndex: number @@ -274,6 +273,7 @@ export interface NotebookStoreState { addRoomFile: (file: File, tableName: string) => Promise closeCellConfig: () => void forceUpdate: () => void + saveToLocalStorage?: () => void initFromConfig: (loadedConfig: unknown) => void setPages: (pages: NotebookPage[]) => void setActivePageIndex: (i: number) => void @@ -284,33 +284,15 @@ export interface NotebookStoreState { getGroups: () => NotebookGroup[] getLinkGroups: () => NotebookGroup[] - // ── db (sqlrooms DuckDb connector) ──────────────────────────────────────── - db: { - schemaTrees: unknown[] - refreshTableSchemas: () => Promise - [key: string]: unknown - } - - // ── layout (sqlrooms RoomShell) ─────────────────────────────────────────── - layout: { - config: { nodes: unknown } - togglePanel: (panel: string, show?: boolean) => void - [key: string]: unknown - } - - // ── room (sqlrooms) ─────────────────────────────────────────────────────── - // TODO: type this properly — room comes from sqlrooms BaseRoomStoreState - room?: { initialized: boolean; initialize: () => Promise; destroy: () => Promise; captureException: (exception: unknown, captureContext?: unknown) => void; [key: string]: unknown } - // ── Slices Zustand purs — méthodes ─────────────────────────────────────── // pagesSlice addPage: () => void deletePage: (index: number) => Promise - startPageDrag: (index: number, event: { dataTransfer: DataTransfer; [key: string]: unknown }) => void - onPageDragOver: (index: number, event: { preventDefault: () => void; [key: string]: unknown }) => void + startPageDrag: (index: number, event: DragEvent) => void + onPageDragOver: (index: number, event: DragEvent) => void onPageDragLeave: () => void - onPageDrop: (targetIndex: number, event: { preventDefault: () => void; [key: string]: unknown }) => void + onPageDrop: (targetIndex: number, event: DragEvent) => void endPageDrag: () => void switchPage: (index: number) => void activatePage: (index: number) => Promise @@ -378,7 +360,7 @@ export interface NotebookStoreState { getGroupAtPath: (path: number[]) => NotebookGroup | null getParentGroup: (path: number[]) => NotebookGroup | null getCellAtPath: (path: number[], cellIndex: number) => NotebookCell | undefined - createNewGroup: (direction?: string) => NotebookGroup + createNewGroup: (direction?: 'row' | 'column') => NotebookGroup addNestedGroup: (path: number[]) => void toggleGroupDirection: (path: number[]) => void openLoopConfigModal: (path: number[]) => void @@ -516,8 +498,26 @@ export interface NotebookStoreState { copyGroupAtPath: (pathOrIndex: number | number[]) => void pasteToGroup: (pathOrIndex: number | number[]) => void - // ── Index signature pour compatibilité avec les méthodes sqlrooms ───────── - // (sqlEditor, roomShell, cells, notebook, canvas ont leurs propres types - // non exportés publiquement — on les laisse en unknown pour éviter any) + // ── Index signature pour les méthodes sqlrooms non réexportées ─────────── [key: string]: unknown } + +// ─── Type public du store ────────────────────────────────────────────────────── + +/** + * NotebookStoreState — type complet du store Zustand sqljob. + * + * Composé par intersection : + * - Slice state types sqlrooms (roomShell, sqlEditor, cells, notebook, canvas) + * - CustomNotebookState (état propre à sqljob) + * + * Ce pattern suit les exemples officiels sqlrooms (ex: notebook/src/store.ts) + * où RoomState = RoomShellSliceState & ArtifactsSliceState & ... & { custom } + */ +export type NotebookStoreState = + RoomShellSliceState & + SqlEditorSliceState & + CellsSliceState & + NotebookSliceState & + CanvasSliceState & + CustomNotebookState