diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 8f9c940..fe63ac6 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -1,27 +1,18 @@ -name: Build and Deploy to GitHub Pages +name: CI Validate Pull Request on: - push: - branches: - - main pull_request: branches: - main permissions: contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false jobs: - build: - name: Build Application + validate: + name: Build and Test runs-on: ubuntu-latest - + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -57,24 +48,8 @@ jobs: echo "Server did not respond after 60s" exit 1 - - name: Build application - run: npm run build + - name: Run E2E tests + run: npx cucumber-js - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./dist - - deploy: - name: Deploy to GitHub Pages - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + - name: Build application + run: npx vite build diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml new file mode 100644 index 0000000..04a447c --- /dev/null +++ b/.github/workflows/ci-dev.yml @@ -0,0 +1,105 @@ +name: CI – Dev Branch + +on: + push: + branches: + - Dev + +permissions: + contents: read + +jobs: + # ------------------------------------------------------------------ + # 1. Run all E2E tests + # ------------------------------------------------------------------ + test: + name: E2E Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser and system dependencies + run: npx playwright install --with-deps chromium + + - name: Start dev server + run: nohup npx vite --host > /tmp/vite.log 2>&1 & + + - name: Wait for dev server + run: | + echo "Waiting for Vite dev server..." + for i in $(seq 1 30); do + if curl -sf http://localhost:5173/SolutionInventory/ > /dev/null 2>&1; then + echo "Server is up after ${i} attempts" + exit 0 + fi + echo "Attempt $i failed, retrying in 2s..." + sleep 2 + done + echo "--- Vite log ---" + cat /tmp/vite.log + echo "Server did not respond after 60s" + exit 1 + + - name: Run E2E tests + run: npx cucumber-js + + # ------------------------------------------------------------------ + # 2. Verify Electron app builds (no release, no publish) + # ------------------------------------------------------------------ + build-electron-check: + name: Electron Build Check (${{ matrix.artifact_name }}) + needs: test + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact_name: linux-x64 + electron_flags: --linux --x64 + - os: windows-latest + artifact_name: windows-x64 + electron_flags: --win --x64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install system dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + libx11-xcb1 libxrandr2 libxcomposite1 libxcursor1 libxdamage1 \ + libxfixes3 libxi6 libgtk-3-0t64 libatk1.0-0t64 libcairo-gobject2 \ + libgdk-pixbuf-2.0-0 libasound2t64 libgtk-4-1 libvulkan1 libopus0 \ + libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 \ + libgstreamer-plugins-bad1.0-0 libflite1 libwebp7 libharfbuzz-icu0 \ + libwayland-server0 libmanette-0.2-0 libenchant-2-2 libgbm1 libdrm2 \ + libhyphen0 libgles2 rpm fakeroot dpkg + + - name: Install dependencies + run: npm ci + + - name: Build Vite + Electron app (no publish) + run: npx vite build --mode electron && npx electron-builder ${{ matrix.electron_flags }} --publish never + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 09747aa..70ae11c 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -1,28 +1,87 @@ -name: Build and Release Electron App +name: Release on Merge to Main on: push: branches: - - dev - tags: - - 'v*' - workflow_dispatch: + - main permissions: contents: write + pages: write + id-token: write + +concurrency: + group: release-${{ github.run_id }} + cancel-in-progress: false jobs: - build-and-release: - name: Build and Release + # ------------------------------------------------------------------ + # 1. Bump the minor version, commit it back to main and create a tag + # ------------------------------------------------------------------ + version: + name: Bump Version + runs-on: ubuntu-latest + # Prevent infinite loop: skip if the commit was created by this workflow + if: "!contains(github.event.head_commit.message, '[skip ci]')" + outputs: + version: ${{ steps.bump.outputs.version }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Configure Git identity + run: | + git config user.email "ci@github-actions" + git config user.name "GitHub Actions" + + - name: Bump minor version and push + id: bump + run: | + npm version minor --no-git-tag-version + VERSION=$(node -p "require('./package.json').version") + echo "version=${VERSION}" >> $GITHUB_OUTPUT + git add package.json package-lock.json + git commit -m "chore: release v${VERSION} [skip ci]" + git tag "v${VERSION}" + git push origin main --follow-tags + + # ------------------------------------------------------------------ + # 2. Build the Electron app (parallel matrix: Linux + Windows) + # ------------------------------------------------------------------ + build-electron: + name: Build Electron (${{ matrix.artifact_name }}) + needs: version runs-on: ${{ matrix.os }} - + strategy: + fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - + include: + - os: ubuntu-latest + artifact_name: linux-x64 + electron_flags: --linux --x64 + - os: windows-latest + artifact_name: windows-x64 + electron_flags: --win --x64 + steps: - - name: Checkout repository + - name: Checkout tagged release uses: actions/checkout@v4 + with: + ref: v${{ needs.version.outputs.version }} - name: Setup Node.js uses: actions/setup-node@v4 @@ -30,62 +89,32 @@ jobs: node-version: 20 cache: npm - - name: Install system dependencies for Playwright (Ubuntu) + - name: Install system dependencies (Ubuntu) if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y \ - libx11-xcb1 \ - libxrandr2 \ - libxcomposite1 \ - libxcursor1 \ - libxdamage1 \ - libxfixes3 \ - libxi6 \ - libgtk-3-0t64 \ - libatk1.0-0t64 \ - libcairo-gobject2 \ - libgdk-pixbuf-2.0-0 \ - libasound2t64 \ - libgtk-4-1 \ - libvulkan1 \ - libopus0 \ - libgstreamer1.0-0 \ - libgstreamer-plugins-base1.0-0 \ - libgstreamer-plugins-bad1.0-0 \ - libflite1 \ - libwebp7 \ - libharfbuzz-icu0 \ - libwayland-server0 \ - libmanette-0.2-0 \ - libenchant-2-2 \ - libgbm1 \ - libdrm2 \ - libhyphen0 \ - libgles2 + libx11-xcb1 libxrandr2 libxcomposite1 libxcursor1 libxdamage1 \ + libxfixes3 libxi6 libgtk-3-0t64 libatk1.0-0t64 libcairo-gobject2 \ + libgdk-pixbuf-2.0-0 libasound2t64 libgtk-4-1 libvulkan1 libopus0 \ + libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 \ + libgstreamer-plugins-bad1.0-0 libflite1 libwebp7 libharfbuzz-icu0 \ + libwayland-server0 libmanette-0.2-0 libenchant-2-2 libgbm1 libdrm2 \ + libhyphen0 libgles2 rpm fakeroot dpkg - name: Install dependencies run: npm ci - - name: Build Electron app - run: npm run electron:build + - name: Build Vite + Electron app + run: npx vite build --mode electron && npx electron-builder ${{ matrix.electron_flags }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload artifacts (macOS) - if: matrix.os == 'macos-latest' - uses: actions/upload-artifact@v4 - with: - name: macos-release - path: | - release/*.dmg - release/*.zip - - - name: Upload artifacts (Ubuntu) + - name: Upload artifacts (Linux) if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v4 with: - name: linux-release + name: ${{ matrix.artifact_name }} path: | release/*.AppImage release/*.deb @@ -94,16 +123,17 @@ jobs: if: matrix.os == 'windows-latest' uses: actions/upload-artifact@v4 with: - name: windows-release - path: | - release/*.exe - + name: ${{ matrix.artifact_name }} + path: release/*.exe + + # ------------------------------------------------------------------ + # 3. Create a GitHub Release with all Electron artifacts + # ------------------------------------------------------------------ create-release: name: Create GitHub Release - needs: build-and-release + needs: [version, build-electron] runs-on: ubuntu-latest - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - + steps: - name: Download all artifacts uses: actions/download-artifact@v4 @@ -111,11 +141,54 @@ jobs: path: artifacts - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: - files: | - artifacts/**/* - draft: false + tag_name: v${{ needs.version.outputs.version }} + name: "Release v${{ needs.version.outputs.version }}" + body: | + ?? **Release v${{ needs.version.outputs.version }}** + + Automatically built and released on merge to `main`. + Commit: ${{ github.sha }} prerelease: false + files: artifacts/**/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ------------------------------------------------------------------ + # 4. Build the web app and deploy to GitHub Pages + # ------------------------------------------------------------------ + deploy-pages: + name: Deploy GitHub Pages + needs: version + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Checkout tagged release + uses: actions/checkout@v4 + with: + ref: v${{ needs.version.outputs.version }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build web application + run: npx vite build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 7caa862..f9276e4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Solution Inventory +

+ Solution Inventory +

+ Vue 3 + Vuetify application for documenting solution questionnaires across multiple projects. Available as both a Progressive Web App (PWA) and an Electron desktop application. It uses a project tree for navigation, questionnaire tabs for editing, and a configuration editor in a dialog. ## Features diff --git a/electron/main.js b/electron/main.js index e25e056..4ac3e3a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,36 +1,94 @@ -const { app, BrowserWindow, ipcMain } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog } = require('electron'); const path = require('path'); const fs = require('fs'); -// Handle creating/removing shortcuts on Windows when installing/uninstalling. -if (require('electron-squirrel-startup')) { - app.quit(); +const DATA_FILE_NAME = 'solution-inventory-data.json'; + +function getConfigPath() { + return path.join(app.getPath('userData'), 'app-config.json'); +} + +function readConfig() { + try { + const configPath = getConfigPath(); + if (fs.existsSync(configPath)) { + return JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } + } catch (e) { + console.error('Error reading config:', e); + } + return {}; +} + +function writeConfig(config) { + try { + fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2)); + } catch (e) { + console.error('Error writing config:', e); + } } let mainWindow; +let splashWindow; + +function getLogoPath(size) { + if (size === 'icon') { + return app.isPackaged + ? path.join(__dirname, '../dist/icon.ico') + : path.join(__dirname, '../public/icon.ico'); + } + const name = size === 'small' ? 'Logo-small.png' : 'Logo-Large.png'; + return app.isPackaged + ? path.join(__dirname, '../dist', name) + : path.join(__dirname, '../public', name); +} + +function createSplashWindow() { + splashWindow = new BrowserWindow({ + width: 360, + height: 320, + frame: false, + transparent: false, + resizable: false, + center: true, + skipTaskbar: true, + icon: process.platform === 'win32' ? getLogoPath('icon') : getLogoPath('large') + }); + splashWindow.loadFile(path.join(__dirname, 'splash.html'), { + query: { logo: getLogoPath('large') } + }); +} function createWindow() { - // Create the browser window. mainWindow = new BrowserWindow({ width: 1200, height: 800, + show: false, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') }, - icon: path.join(__dirname, '../public/icon.png') + icon: process.platform === 'win32' ? getLogoPath('icon') : getLogoPath('large') }); // In production, load the built files if (app.isPackaged) { mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); } else { - // In development, load from Vite dev server mainWindow.loadURL('http://localhost:5173'); mainWindow.webContents.openDevTools(); } + mainWindow.webContents.once('did-finish-load', () => { + if (splashWindow && !splashWindow.isDestroyed()) { + splashWindow.close(); + splashWindow = null; + } + mainWindow.show(); + mainWindow.focus(); + }); + mainWindow.on('closed', () => { mainWindow = null; }); @@ -39,6 +97,7 @@ function createWindow() { // This method will be called when Electron has finished // initialization and is ready to create browser windows. app.whenReady().then(() => { + createSplashWindow(); createWindow(); app.on('activate', () => { @@ -57,42 +116,53 @@ app.on('window-all-closed', () => { } }); -// IPC handlers for file operations -ipcMain.handle('save-file', async (event, { filePath, data }) => { +// IPC handlers for workspace directory management +ipcMain.handle('get-workspace-dir', async () => { + const config = readConfig(); + return config.workspaceDir || null; +}); + +ipcMain.handle('set-workspace-dir', async (event, dirPath) => { + const config = readConfig(); + config.workspaceDir = dirPath; + writeConfig(config); + return { success: true }; +}); + +ipcMain.handle('select-workspace-dir', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Select Workspace Directory', + properties: ['openDirectory', 'createDirectory'], + buttonLabel: 'Use as Workspace' + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; +}); + +// IPC handlers for data file I/O in the workspace directory +ipcMain.handle('read-data-file', async () => { try { - const userDataPath = app.getPath('userData'); - const fullPath = path.join(userDataPath, filePath); - const dir = path.dirname(fullPath); - - // Create directory if it doesn't exist - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - fs.writeFileSync(fullPath, JSON.stringify(data, null, 2)); - return { success: true, path: fullPath }; + const config = readConfig(); + if (!config.workspaceDir) return { success: false, error: 'No workspace dir configured' }; + const filePath = path.join(config.workspaceDir, DATA_FILE_NAME); + if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' }; + const content = fs.readFileSync(filePath, 'utf-8'); + return { success: true, data: JSON.parse(content) }; } catch (error) { - console.error('Error saving file:', error); + console.error('Error reading data file:', error); return { success: false, error: error.message }; } }); -ipcMain.handle('load-file', async (event, filePath) => { +ipcMain.handle('write-data-file', async (event, jsonString) => { try { - const userDataPath = app.getPath('userData'); - const fullPath = path.join(userDataPath, filePath); - - if (fs.existsSync(fullPath)) { - const data = fs.readFileSync(fullPath, 'utf-8'); - return { success: true, data: JSON.parse(data) }; - } - return { success: false, error: 'File not found' }; + const config = readConfig(); + if (!config.workspaceDir) return { success: false, error: 'No workspace dir configured' }; + const filePath = path.join(config.workspaceDir, DATA_FILE_NAME); + fs.writeFileSync(filePath, jsonString); + return { success: true, path: filePath }; } catch (error) { - console.error('Error loading file:', error); + console.error('Error writing data file:', error); return { success: false, error: error.message }; } }); - -ipcMain.handle('get-user-data-path', async () => { - return app.getPath('userData'); -}); diff --git a/electron/preload.js b/electron/preload.js index ae41bb3..a537500 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -4,7 +4,11 @@ const { contextBridge, ipcRenderer } = require('electron'); // the ipcRenderer without exposing the entire object contextBridge.exposeInMainWorld('electronAPI', { isElectron: true, - saveFile: (filePath, data) => ipcRenderer.invoke('save-file', { filePath, data }), - loadFile: (filePath) => ipcRenderer.invoke('load-file', filePath), - getUserDataPath: () => ipcRenderer.invoke('get-user-data-path') + // Workspace directory management + getWorkspaceDir: () => ipcRenderer.invoke('get-workspace-dir'), + setWorkspaceDir: (dirPath) => ipcRenderer.invoke('set-workspace-dir', dirPath), + selectWorkspaceDir: () => ipcRenderer.invoke('select-workspace-dir'), + // Data file I/O (replaces localStorage in Electron mode) + readDataFile: () => ipcRenderer.invoke('read-data-file'), + writeDataFile: (jsonString) => ipcRenderer.invoke('write-data-file', jsonString), }); diff --git a/electron/splash.html b/electron/splash.html new file mode 100644 index 0000000..10d64f9 --- /dev/null +++ b/electron/splash.html @@ -0,0 +1,83 @@ + + + + + + Solution Inventory + + + +
+ Solution Inventory +
Solution Inventory
+
Loading…
+
+
+ + + diff --git a/index.html b/index.html index fca17c4..b9b2aef 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,10 @@ - + Solution Inventory + diff --git a/package.json b/package.json index 29c866c..0d8bb02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solution-inventory-pwa", - "version": "0.1.0", + "version": "0.9.0", "description": "Solution Inventory - A questionnaire-based application for project assessment", "author": "Hendrik Lösch ", "private": true, @@ -35,6 +35,7 @@ "build": { "appId": "com.solutioninventory.app", "productName": "Solution Inventory", + "icon": "public/Logo-Large", "files": [ "dist/**/*", "electron/**/*" @@ -50,6 +51,7 @@ ] }, "win": { + "icon": "public/icon.ico", "target": [ "nsis", "portable" diff --git a/public/Logo-Large.png b/public/Logo-Large.png new file mode 100644 index 0000000..af6d769 Binary files /dev/null and b/public/Logo-Large.png differ diff --git a/public/Logo-small.png b/public/Logo-small.png new file mode 100644 index 0000000..d4cad93 Binary files /dev/null and b/public/Logo-small.png differ diff --git a/public/icon.ico b/public/icon.ico new file mode 100644 index 0000000..0e1769d Binary files /dev/null and b/public/icon.ico differ diff --git a/public/pwa-192.png b/public/pwa-192.png deleted file mode 100644 index d252638..0000000 --- a/public/pwa-192.png +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/pwa-512.png b/public/pwa-512.png deleted file mode 100644 index d252638..0000000 --- a/public/pwa-512.png +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index ec6e30d..1c38d96 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,6 +5,14 @@ mdi-menu Toggle sidebar + Solution Inventory @@ -46,6 +54,47 @@ + + + + + Set Up Workspace + + +

+ Please choose a directory where your workspace data will be stored. + The data will be saved as solution-inventory-data.json in that directory. +

+ + + +
+ + + + + Confirm + + +
+
@@ -95,11 +144,25 @@ export default { document.removeEventListener('mouseup', stopResize) }) const store = useWorkspaceStore() - const { lastSaved } = storeToRefs(store) + const { lastSaved, workspaceDirNeeded } = storeToRefs(store) + + const isElectron = !!(window.electronAPI) + const baseUrl = import.meta.env.BASE_URL + const workspaceSetupDir = ref('') + + async function selectDirectory() { + const dir = await window.electronAPI.selectWorkspaceDir() + if (dir) workspaceSetupDir.value = dir + } + + async function confirmWorkspace() { + if (!workspaceSetupDir.value) return + await store.setWorkspaceDir(workspaceSetupDir.value) + } // Beim Start versuchen, gespeicherte Daten zu laden - onMounted(() => { - store.initFromStorage() + onMounted(async () => { + await store.initFromStorage() store.startAutoSave() }) @@ -109,7 +172,13 @@ export default { drawerOpen, drawerWidth, startResize, - workspaceConfigOpen + workspaceConfigOpen, + isElectron, + baseUrl, + workspaceDirNeeded, + workspaceSetupDir, + selectDirectory, + confirmWorkspace } } } diff --git a/src/components/TreeNav.vue b/src/components/TreeNav.vue index a1bd0a3..6defe43 100644 --- a/src/components/TreeNav.vue +++ b/src/components/TreeNav.vue @@ -563,7 +563,12 @@ export default { categories: item.categories } }) - store.importProject(projectName, questionnaires) + const radarData = { + radarRefs: Array.isArray(data?.project?.radarRefs) ? data.project.radarRefs : [], + radarOverrides: Array.isArray(data?.project?.radarOverrides) ? data.project.radarOverrides : [], + radarCategoryOrder: Array.isArray(data?.project?.radarCategoryOrder) ? data.project.radarCategoryOrder : [] + } + store.importProject(projectName, questionnaires, radarData) closeImportDialog() } catch (err) { importError.value = `Import failed: ${err.message}` diff --git a/src/stores/workspaceStore.js b/src/stores/workspaceStore.js index 4c4f1fb..7f1a72f 100644 --- a/src/stores/workspaceStore.js +++ b/src/stores/workspaceStore.js @@ -42,6 +42,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { const lastSaved = ref('') const autoSaveStarted = ref(false) const pendingNavigation = ref(null) // { questionnaireId, categoryId, entryId } | null + const workspaceDirNeeded = ref(false) const activeQuestionnaire = computed(() => { return workspace.value.questionnaires.find((item) => item.id === activeQuestionnaireId.value) || null @@ -86,40 +87,71 @@ export const useWorkspaceStore = defineStore('workspace', () => { return [...projectTabs, ...questionnaireTabs] }) - function initFromStorage() { + function applyStoredData(data) { + if (data.version === STORAGE_VERSION && data.workspace) { + workspace.value = data.workspace + activeQuestionnaireId.value = '' + openQuestionnaireIds.value = [] + activeWorkspaceTabId.value = '' + openProjectSummaryIds.value = [] + hydrateLastSaved(data.timestamp) + return true + } + if (data.version === STORAGE_VERSION && data.categories) { + const initialQuestionnaire = createQuestionnaire('Current questionnaire', data.categories) + workspace.value = createWorkspace([], [initialQuestionnaire]) + activeQuestionnaireId.value = '' + openQuestionnaireIds.value = [] + activeWorkspaceTabId.value = '' + openProjectSummaryIds.value = [] + hydrateLastSaved(data.timestamp) + return true + } + return false + } + + async function initFromStorage() { + // --- Electron: file-based storage --- + if (window.electronAPI) { + const dir = await window.electronAPI.getWorkspaceDir() + if (!dir) { + workspaceDirNeeded.value = true + return + } + workspaceDirNeeded.value = false + const result = await window.electronAPI.readDataFile() + if (result.success) { + try { + if (!applyStoredData(result.data)) seedWorkspace() + } catch (error) { + console.error('Error applying stored data:', error) + seedWorkspace() + } + } else { + seedWorkspace() + } + return + } + + // --- Web: localStorage --- const saved = localStorage.getItem(STORAGE_KEY) if (!saved) { seedWorkspace() return } - try { const data = JSON.parse(saved) - if (data.version === STORAGE_VERSION && data.workspace) { - workspace.value = data.workspace - activeQuestionnaireId.value = '' - openQuestionnaireIds.value = [] - activeWorkspaceTabId.value = '' - openProjectSummaryIds.value = [] - hydrateLastSaved(data.timestamp) - return - } - - if (data.version === STORAGE_VERSION && data.categories) { - const initialQuestionnaire = createQuestionnaire('Current questionnaire', data.categories) - workspace.value = createWorkspace([], [initialQuestionnaire]) - activeQuestionnaireId.value = '' - openQuestionnaireIds.value = [] - activeWorkspaceTabId.value = '' - openProjectSummaryIds.value = [] - hydrateLastSaved(data.timestamp) - return - } + if (!applyStoredData(data)) seedWorkspace() } catch (error) { console.error('Error loading from localStorage:', error) + seedWorkspace() } + } - seedWorkspace() + async function setWorkspaceDir(dirPath) { + if (!window.electronAPI) return + await window.electronAPI.setWorkspaceDir(dirPath) + await initFromStorage() } function seedWorkspace() { @@ -132,31 +164,46 @@ export const useWorkspaceStore = defineStore('workspace', () => { openProjectSummaryIds.value = [] } + let persistDebounceTimer = null + function startAutoSave() { if (autoSaveStarted.value) return autoSaveStarted.value = true watch( () => [workspace.value, activeQuestionnaireId.value, openQuestionnaireIds.value, activeWorkspaceTabId.value, openProjectSummaryIds.value], - () => persist(), + () => { + clearTimeout(persistDebounceTimer) + persistDebounceTimer = setTimeout(() => persist(), 500) + }, { deep: true } ) } - function persist() { - try { - const dataToSave = { - version: STORAGE_VERSION, - timestamp: new Date().toISOString(), - workspace: workspace.value, - activeQuestionnaireId: activeQuestionnaireId.value, - openQuestionnaireIds: openQuestionnaireIds.value, - activeWorkspaceTabId: activeWorkspaceTabId.value, - openProjectSummaryIds: openProjectSummaryIds.value + async function persist() { + const dataToSave = { + version: STORAGE_VERSION, + timestamp: new Date().toISOString(), + workspace: workspace.value, + activeQuestionnaireId: activeQuestionnaireId.value, + openQuestionnaireIds: openQuestionnaireIds.value, + activeWorkspaceTabId: activeWorkspaceTabId.value, + openProjectSummaryIds: openProjectSummaryIds.value + } + + if (window.electronAPI) { + try { + await window.electronAPI.writeDataFile(JSON.stringify(dataToSave, null, 2)) + hydrateLastSaved(dataToSave.timestamp) + } catch (error) { + console.error('Error saving to file:', error) + } + } else { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave)) + hydrateLastSaved(dataToSave.timestamp) + } catch (error) { + console.error('Error saving to localStorage:', error) } - localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave)) - hydrateLastSaved(dataToSave.timestamp) - } catch (error) { - console.error('Error saving to localStorage:', error) } } @@ -292,7 +339,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { return questionnaire.id } - function importProject(projectName, questionnaires) { + function importProject(projectName, questionnaires, radarData = {}) { const name = String(projectName || '').trim() if (!name) return const projectId = addProject(name) @@ -307,6 +354,9 @@ export const useWorkspaceStore = defineStore('workspace', () => { ? [...project.questionnaireIds, created.id] : [created.id] }) + if (Array.isArray(radarData.radarRefs)) project.radarRefs = radarData.radarRefs + if (Array.isArray(radarData.radarOverrides)) project.radarOverrides = radarData.radarOverrides + if (Array.isArray(radarData.radarCategoryOrder)) project.radarCategoryOrder = radarData.radarCategoryOrder } function deleteQuestionnaire(questionnaireId) { @@ -415,7 +465,10 @@ export const useWorkspaceStore = defineStore('workspace', () => { const exportData = { project: { id: project.id, - name: project.name + name: project.name, + radarRefs: Array.isArray(project.radarRefs) ? project.radarRefs : [], + radarOverrides: Array.isArray(project.radarOverrides) ? project.radarOverrides : [], + radarCategoryOrder: Array.isArray(project.radarCategoryOrder) ? project.radarCategoryOrder : [] }, questionnaires } @@ -743,11 +796,13 @@ export const useWorkspaceStore = defineStore('workspace', () => { activeWorkspaceTabId, openProjectSummaryIds, lastSaved, + workspaceDirNeeded, activeQuestionnaire, activeCategories, openTabs, workspaceTabs, initFromStorage, + setWorkspaceDir, startAutoSave, persist, setActiveQuestionnaire, diff --git a/tests/step_definitions/export-import.steps.js b/tests/step_definitions/export-import.steps.js index dd6d8a8..b86198e 100644 --- a/tests/step_definitions/export-import.steps.js +++ b/tests/step_definitions/export-import.steps.js @@ -79,7 +79,12 @@ When('I import the {string} file', async function (filename) { name: q.name || 'Imported questionnaire', categories: q.categories, })) - store.importProject(data.project.name, questionnaires) + const radarData = { + radarRefs: Array.isArray(data?.project?.radarRefs) ? data.project.radarRefs : [], + radarOverrides: Array.isArray(data?.project?.radarOverrides) ? data.project.radarOverrides : [], + radarCategoryOrder: Array.isArray(data?.project?.radarCategoryOrder) ? data.project.radarCategoryOrder : [], + } + store.importProject(data.project.name, questionnaires, radarData) }, importData) }) diff --git a/vite.config.js b/vite.config.js index 6496c94..ef2489b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,12 +1,18 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { VitePWA } from 'vite-plugin-pwa' +import { readFileSync } from 'fs' + +const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) export default defineConfig(({ mode }) => { const isElectron = mode === 'electron'; return { base: isElectron ? './' : '/SolutionInventory/', + define: { + __APP_VERSION__: JSON.stringify(pkg.version) + }, plugins: [ vue(), // Only include PWA plugin for web build @@ -19,8 +25,7 @@ export default defineConfig(({ mode }) => { display: 'standalone', background_color: '#ffffff', icons: [ - { src: '/SolutionInventory/pwa-192.png', sizes: '192x192', type: 'image/png' }, - { src: '/SolutionInventory/pwa-512.png', sizes: '512x512', type: 'image/png' } + { src: '/SolutionInventory/Logo-Large.png', sizes: '512x512', type: 'image/png' } ] } })