From 8029b2cdb13f132aae776a7a718e547406ef9a45 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Mon, 28 Sep 2026 15:28:21 +0200 Subject: [PATCH 01/22] U: Updated gititnore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index fce2cc0..8d0564a 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ dist-ssr /tmp /.mountea-user-data /.steamcmd_probe + +# Local browser automation artifacts +/.playwright-cli From 997a40319ba6f23db44b5b0f51294e8e74a564aa Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Mon, 28 Sep 2026 15:29:37 +0200 Subject: [PATCH 02/22] D: deleted outdated tests --- src/App.test.js | 9 ----- src/lib/assetNaming.test.js | 36 ----------------- src/lib/dialogueImportAudio.test.js | 63 ----------------------------- 3 files changed, 108 deletions(-) delete mode 100644 src/App.test.js delete mode 100644 src/lib/assetNaming.test.js delete mode 100644 src/lib/dialogueImportAudio.test.js diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index 1a6d80a..0000000 --- a/src/App.test.js +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-env jest */ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/lib/assetNaming.test.js b/src/lib/assetNaming.test.js deleted file mode 100644 index 36c511f..0000000 --- a/src/lib/assetNaming.test.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-env jest */ -import { buildParticipantImageId } from '@/lib/participantThumbnails'; -import { - sanitizeAttachmentIdSegment, - sanitizeAudioFileName, - sanitizeUnrealIdentifier, -} from '@/lib/assetNaming'; - -describe('assetNaming', () => { - test('sanitizes identifiers to Unreal-safe tokens', () => { - expect(sanitizeUnrealIdentifier(' Ask for Work ')).toBe('Ask_for_Work'); - expect(sanitizeUnrealIdentifier('český název')).toBe('cesky_nazev'); - expect(sanitizeUnrealIdentifier('***')).toBe('Asset'); - }); - - test('sanitizes attachment segments with fallback', () => { - expect(sanitizeAttachmentIdSegment('A/B>C D')).toBe('A_B_C_D'); - expect(sanitizeAttachmentIdSegment('', 'Unknown')).toBe('Unknown'); - }); - - test('sanitizes audio file names while preserving extension', () => { - expect(sanitizeAudioFileName('Ask for Work.wav')).toBe('Ask_for_Work.wav'); - expect(sanitizeAudioFileName('Můj zvuk.MP3')).toBe('Muj_zvuk.mp3'); - expect(sanitizeAudioFileName(' ???.wav ')).toBe('Audio.wav'); - expect(sanitizeAudioFileName('name with no ext')).toBe('name_with_no_ext'); - }); - - test('buildParticipantImageId uses sanitized category and name', () => { - expect( - buildParticipantImageId({ - participantName: 'Žoldák Hero', - categoryPath: 'Main/Characters > Tier 1', - }) - ).toBe('T_Main_Characters_Tier_1_Zoldak_Hero_Thumbnail'); - }); -}); diff --git a/src/lib/dialogueImportAudio.test.js b/src/lib/dialogueImportAudio.test.js deleted file mode 100644 index f4278f4..0000000 --- a/src/lib/dialogueImportAudio.test.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-env jest */ -import { - getScopedRowAudioFilePaths, - resolveRowAudioImportSelection, -} from './dialogueImportAudio'; - -function createMockRowFolder(allEntries, rowId) { - const prefix = `audio/${rowId}/`; - return { - filter: (predicate) => - allEntries - .filter((entry) => String(entry.name || '').startsWith(prefix)) - .filter((entry) => - predicate(entry.name.slice(prefix.length), { - name: entry.name, - dir: Boolean(entry.dir), - }) - ) - .map((entry) => ({ name: entry.name, dir: Boolean(entry.dir) })), - }; -} - -describe('dialogueImportAudio', () => { - test('selects only files directly under the requested row folder', () => { - const rowFolder = createMockRowFolder([ - { name: 'audio/row-a/Line A.mp3', dir: false }, - { name: 'audio/row-b/Line B.mp3', dir: false }, - { name: 'audio/row-a/nested/skip.mp3', dir: false }, - { name: 'stringTable.json', dir: false }, - ], 'row-a'); - - expect(getScopedRowAudioFilePaths(rowFolder)).toEqual([ - 'audio/row-a/Line A.mp3', - ]); - }); - - test('returns deterministic first file and warning when multiple files exist', () => { - const rowFolder = createMockRowFolder([ - { name: 'audio/row-a/z-last.mp3', dir: false }, - { name: 'audio/row-a/a-first.mp3', dir: false }, - ], 'row-a'); - - expect(resolveRowAudioImportSelection(rowFolder, 'row-a')).toEqual({ - selectedPath: 'audio/row-a/a-first.mp3', - warnings: [ - { - code: 'multiple_row_audio', - rowId: 'row-a', - paths: ['audio/row-a/a-first.mp3', 'audio/row-a/z-last.mp3'], - }, - ], - }); - }); - - test('returns missing warning when row folder has no direct audio file', () => { - const rowFolder = createMockRowFolder([{ name: 'audio/row-b/Other.mp3', dir: false }], 'row-a'); - - expect(resolveRowAudioImportSelection(rowFolder, 'row-a')).toEqual({ - selectedPath: '', - warnings: [{ code: 'missing_row_audio', rowId: 'row-a' }], - }); - }); -}); From 6caa45930f1a202ae0aea949fae93df80a56b756 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:29:41 +0200 Subject: [PATCH 03/22] U: Consolidate Steam upload scripts Move shared upload logic into scripts/steam-upload.mjs; the Windows, Linux and macOS scripts are now thin wrappers around it. --- scripts/steam-upload.mjs | 53 +++++++ steam_build_upload.bat | 72 +-------- steam_build_upload_linux.sh | 290 +----------------------------------- steam_build_upload_mac.sh | 224 +--------------------------- 4 files changed, 57 insertions(+), 582 deletions(-) create mode 100644 scripts/steam-upload.mjs diff --git a/scripts/steam-upload.mjs b/scripts/steam-upload.mjs new file mode 100644 index 0000000..6cd4055 --- /dev/null +++ b/scripts/steam-upload.mjs @@ -0,0 +1,53 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +export function parseArguments(args) { + const options = {}; + for (let index = 0; index < args.length; index++) { + const key = args[index].replace(/^--/, ''); + if (['execute', 'upload', 'dry-run'].includes(key)) options[key] = true; + else if (['source', 'staging-root', 'steamcmd', 'username', 'app-id', 'depot-id', 'platform'].includes(key) && args[index + 1] && !args[index + 1].startsWith('--')) options[key] = args[++index]; + else throw new Error(`Unsupported or incomplete argument: ${args[index]}`); + } + return options; +} +const contains = (root, target) => { const relative = path.relative(root, target); return !relative || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); }; +export async function planSteamUpload(options) { + for (const key of ['source', 'staging-root', 'username', 'app-id', 'depot-id', 'platform']) if (!options[key]) throw new Error(`Required argument: --${key}`); + for (const key of ['app-id', 'depot-id']) if (!/^[1-9][0-9]*$/.test(options[key])) throw new Error(`${key} must be a positive numeric ID.`); + if (!/^[A-Za-z0-9_]+$/.test(options.username)) throw new Error('Steam account must contain only letters, digits or underscores.'); + if (!['windows', 'macos', 'linux'].includes(options.platform)) throw new Error('Platform must be windows, macos or linux.'); + const source = await fs.realpath(options.source), root = await fs.realpath(options['staging-root']); + for (const directory of [source, root]) if (directory === path.parse(directory).root || !((await fs.stat(directory)).isDirectory())) throw new Error('Source and staging root must be explicit non-root directories.'); + if (contains(source, root) || contains(root, source)) throw new Error('Source and staging root must be separate directories.'); + if (/['"\r\n]/.test(source + root)) throw new Error('Paths cannot contain quotes or newlines.'); + const destination = path.join(root, `mountea-${options['app-id']}-${options.platform}-${Date.now()}`); + if (!contains(root, destination)) throw new Error('Staging destination escaped its root.'); + const preview = !options.upload; + const vdf = `"AppBuild"\n{\n "AppID" "${options['app-id']}"\n "Desc" "Validated ${options.platform} package"\n "Preview" "${preview ? '1' : '0'}"\n "ContentRoot" "${destination.replaceAll('\\', '/')}/content"\n "BuildOutput" "${destination.replaceAll('\\', '/')}/output"\n "Depots" { "${options['depot-id']}" { "FileMapping" { "LocalPath" "*" "DepotPath" "." "Recursive" "1" } } }\n}\n`; + return { source, stagingRoot: root, destination, vdf, preview, username: options.username, execute: Boolean(options.execute && !options['dry-run']), upload: Boolean(options.upload), steamcmd: options.steamcmd }; +} +export async function executeSteamPlan(plan) { + if (!plan.execute) return { dryRun: true, destination: plan.destination, preview: plan.preview }; + if (!plan.steamcmd) throw new Error('--steamcmd must name an installed executable for --execute.'); + const executable = await fs.realpath(plan.steamcmd); + if (!((await fs.stat(executable)).isFile())) throw new Error('SteamCMD executable is missing.'); + // Never mirror-delete another directory. Each run owns a newly allocated + // staging directory; an existing destination fails before any copy. + await fs.mkdir(plan.destination); + await fs.cp(plan.source, path.join(plan.destination, 'content'), { recursive: true, errorOnExist: true, force: false }); + await fs.mkdir(path.join(plan.destination, 'output')); + const vdfPath = path.join(plan.destination, 'app-build.vdf'); + await fs.writeFile(vdfPath, plan.vdf, { flag: 'wx' }); + const args = ['+login', plan.username, '+run_app_build', vdfPath, '+quit']; + const child = spawn(executable.endsWith('.sh') ? 'bash' : executable, executable.endsWith('.sh') ? [executable, ...args] : args, { stdio: 'inherit', windowsHide: true, shell: false }); + const code = await new Promise((resolve, reject) => { child.on('error', reject); child.on('exit', resolve); }); + if (code !== 0) throw new Error(`SteamCMD failed (${code}); staged files remain for inspection.`); + return { dryRun: false, uploaded: plan.upload, destination: plan.destination }; +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const plan = await planSteamUpload(parseArguments(process.argv.slice(2))); + console.log(JSON.stringify(await executeSteamPlan(plan), null, 2)); +} diff --git a/steam_build_upload.bat b/steam_build_upload.bat index c7ee036..5edb462 100644 --- a/steam_build_upload.bat +++ b/steam_build_upload.bat @@ -1,72 +1,4 @@ @echo off setlocal enableextensions - -rem Usage: -rem steam_build_upload.bat -rem steam_build_upload.bat your_steam_build_username - -set "REPO_DIR=%~dp0" -if "%REPO_DIR:~-1%"=="\" set "REPO_DIR=%REPO_DIR:~0,-1%" - -set "STEAM_SDK_ROOT=D:\Program Files\Steam\SDK" -set "CONTENT_BUILDER_DIR=%STEAM_SDK_ROOT%\tools\ContentBuilder" -set "STEAM_BUILDER_DIR=%CONTENT_BUILDER_DIR%\builder" -set "STEAM_CONTENT_DIR=%CONTENT_BUILDER_DIR%\content\windows" -set "STEAM_APP_BUILD_VDF=..\scripts\app_build_4509320.vdf" - -set "SRC_DIR=%REPO_DIR%\release\win-unpacked" -set "STEAM_USERNAME=%~1" -if "%STEAM_USERNAME%"=="" set "STEAM_USERNAME=semjonprophet" - -echo. -echo [1/3] Building Steam package... -pushd "%REPO_DIR%" || goto :fail -call npm run electron:pack:steam -if errorlevel 1 ( - popd - goto :fail -) -popd - -if not exist "%SRC_DIR%\" ( - echo ERROR: Build output not found: "%SRC_DIR%" - goto :fail -) - -echo. -echo [2/3] Copying build to Steam ContentBuilder... -if not exist "%STEAM_CONTENT_DIR%" mkdir "%STEAM_CONTENT_DIR%" -robocopy "%SRC_DIR%" "%STEAM_CONTENT_DIR%" /MIR /R:2 /W:2 >nul -set "ROBO_RC=%ERRORLEVEL%" -if %ROBO_RC% GEQ 8 ( - echo ERROR: Robocopy failed with code %ROBO_RC%. - goto :fail -) - -echo. -echo [3/3] Uploading build to Steam depot... -if not exist "%STEAM_BUILDER_DIR%\steamcmd.exe" ( - echo ERROR: steamcmd.exe not found at "%STEAM_BUILDER_DIR%\steamcmd.exe" - goto :fail -) - -pushd "%STEAM_BUILDER_DIR%" || goto :fail -.\steamcmd.exe +login "%STEAM_USERNAME%" +run_app_build "%STEAM_APP_BUILD_VDF%" +quit -set "STEAMCMD_RC=%ERRORLEVEL%" -popd - -if not "%STEAMCMD_RC%"=="0" ( - echo ERROR: SteamCMD failed with code %STEAMCMD_RC%. - goto :fail -) - -echo. -echo SUCCESS: Build packed, copied, and uploaded. -echo Next: set the new BuildID live on your Steam branch in Steamworks. -exit /b 0 - -:fail -echo. -echo FAILED. -exit /b 1 - +node "%~dp0scripts\steam-upload.mjs" --platform windows %* +exit /b %errorlevel% diff --git a/steam_build_upload_linux.sh b/steam_build_upload_linux.sh index 10c1672..89c6c95 100755 --- a/steam_build_upload_linux.sh +++ b/steam_build_upload_linux.sh @@ -1,292 +1,4 @@ #!/usr/bin/env bash set -euo pipefail - -# Usage: -# ./steam_build_upload_linux.sh [steam_build_username] -# Optional environment overrides: -# STEAM_SDK_ROOT -# CONTENT_BUILDER_DIR -# STEAM_BUILDER_DIR -# STEAM_CONTENT_DIR -# STEAM_APP_BUILD_VDF -# STEAM_APP_ID -# STEAM_DEPOT_ID_LINUX (defaults to 4509323 for this project) -# STEAM_BUILD_DESC -# STEAM_SET_LIVE -# STEAM_PREVIEW (0|1) -# STEAM_USERNAME - REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -if [[ -n "${STEAM_SDK_ROOT:-}" ]]; then - STEAM_SDK_ROOT="$STEAM_SDK_ROOT" -else - # Common Linux custom install locations (including local Documents SDK extractions). - for sdk_glob in "$HOME/Documents/Steam/steamworks_sdk_"*/sdk "$HOME/Documents/Steam/steamworks_sdk_"*; do - if [[ -d "$sdk_glob" ]]; then - STEAM_SDK_ROOT="$sdk_glob" - break - fi - done - - for sdk_candidate in \ - "$HOME/SteamworksSDK/sdk" \ - "$HOME/SteamworksSDK" \ - "$HOME/Steam/steamapps/common/Steamworks SDK/sdk" \ - "$HOME/Steam/steamapps/common/Steamworks SDK" \ - "$HOME/.steam/steam/steamapps/common/Steamworks SDK/sdk" \ - "$HOME/.steam/steam/steamapps/common/Steamworks SDK"; do - if [[ -n "${STEAM_SDK_ROOT:-}" ]]; then - break - fi - if [[ -d "$sdk_candidate" ]]; then - STEAM_SDK_ROOT="$sdk_candidate" - break - fi - done - STEAM_SDK_ROOT="${STEAM_SDK_ROOT:-$HOME/SteamworksSDK/sdk}" -fi - -if [[ -n "${CONTENT_BUILDER_DIR:-}" ]]; then - CONTENT_BUILDER_DIR="$CONTENT_BUILDER_DIR" -elif [[ -d "$STEAM_SDK_ROOT/tools/ContentBuilder" ]]; then - CONTENT_BUILDER_DIR="$STEAM_SDK_ROOT/tools/ContentBuilder" -elif [[ -d "$STEAM_SDK_ROOT/sdk/tools/ContentBuilder" ]]; then - CONTENT_BUILDER_DIR="$STEAM_SDK_ROOT/sdk/tools/ContentBuilder" -else - CONTENT_BUILDER_DIR="$STEAM_SDK_ROOT/tools/ContentBuilder" -fi - -STEAM_BUILDER_DIR="${STEAM_BUILDER_DIR:-$CONTENT_BUILDER_DIR/builder_linux}" -STEAM_CONTENT_DIR="${STEAM_CONTENT_DIR:-$CONTENT_BUILDER_DIR/content/linux}" -STEAM_APP_BUILD_VDF="${STEAM_APP_BUILD_VDF:-}" -STEAM_APP_ID="${STEAM_APP_ID:-4509320}" -STEAM_DEPOT_ID_LINUX="${STEAM_DEPOT_ID_LINUX:-4509323}" -STEAM_BUILD_DESC="${STEAM_BUILD_DESC:-Linux build $(date '+%Y-%m-%d %H:%M:%S')}" -STEAM_SET_LIVE="${STEAM_SET_LIVE:-}" -STEAM_PREVIEW="${STEAM_PREVIEW:-0}" -STEAM_USERNAME="${STEAM_USERNAME:-${1:-semjonprophet}}" -GENERATED_VDF_DIR="" - -find_linux_build_dir() { - local release_dir="$REPO_DIR/release" - local candidate="" - local candidates=( - "$release_dir/linux-unpacked" - "$release_dir/linux-arm64-unpacked" - "$release_dir/linux-ia32-unpacked" - "$release_dir/linux" - ) - - for candidate in "${candidates[@]}"; do - if [[ -d "$candidate" ]]; then - echo "$candidate" - return 0 - fi - done - - return 1 -} - -resolve_steamcmd_bin() { - local candidates=( - "$STEAM_BUILDER_DIR/steamcmd.sh" - "$STEAM_BUILDER_DIR/steamcmd" - "$CONTENT_BUILDER_DIR/builder_linux/steamcmd.sh" - "$CONTENT_BUILDER_DIR/builder/steamcmd.sh" - ) - local cmd="" - local cmd_dir="" - local wrapped_bin="" - - for cmd in "${candidates[@]}"; do - cmd_dir="$(dirname "$cmd")" - wrapped_bin="$cmd_dir/linux32/steamcmd" - if [[ -f "$wrapped_bin" ]] && [[ ! -x "$wrapped_bin" ]]; then - chmod +x "$wrapped_bin" >/dev/null 2>&1 || true - fi - if [[ -f "$cmd" ]] && [[ ! -x "$cmd" ]]; then - chmod +x "$cmd" >/dev/null 2>&1 || true - fi - if [[ -x "$cmd" ]]; then - echo "$cmd" - return 0 - fi - done - - if command -v steamcmd >/dev/null 2>&1; then - command -v steamcmd - return 0 - fi - - return 1 -} - -ensure_steamcmd_runtime() { - local cmd="$1" - local wrapped_bin="" - - if [[ "$cmd" == *.sh ]]; then - wrapped_bin="$(dirname "$cmd")/linux32/steamcmd" - else - wrapped_bin="$cmd" - fi - - if [[ ! -f "$wrapped_bin" ]]; then - return 0 - fi - - if file "$wrapped_bin" 2>/dev/null | grep -q "ELF 32-bit"; then - if [[ ! -e /lib/ld-linux.so.2 ]] && [[ ! -e /lib32/ld-linux.so.2 ]]; then - echo "ERROR: SteamCMD requires 32-bit glibc loader (ld-linux.so.2), but it is missing." >&2 - echo "Install on Ubuntu:" >&2 - echo " sudo dpkg --add-architecture i386 && sudo apt update" >&2 - echo " sudo apt install libc6:i386 libstdc++6:i386 libgcc-s1:i386" >&2 - return 1 - fi - fi - - return 0 -} - -create_temp_app_build_vdf() { - if [[ -z "$STEAM_DEPOT_ID_LINUX" ]]; then - echo "ERROR: STEAM_DEPOT_ID_LINUX is required when STEAM_APP_BUILD_VDF is not provided." >&2 - return 1 - fi - - if [[ ! "$STEAM_APP_ID" =~ ^[0-9]+$ ]]; then - echo "ERROR: STEAM_APP_ID must be numeric (current: \"$STEAM_APP_ID\")." >&2 - return 1 - fi - - if [[ ! "$STEAM_DEPOT_ID_LINUX" =~ ^[0-9]+$ ]]; then - echo "ERROR: STEAM_DEPOT_ID_LINUX must be numeric (current: \"$STEAM_DEPOT_ID_LINUX\")." >&2 - return 1 - fi - - if [[ ! "$STEAM_PREVIEW" =~ ^[01]$ ]]; then - echo "ERROR: STEAM_PREVIEW must be 0 or 1 (current: \"$STEAM_PREVIEW\")." >&2 - return 1 - fi - - GENERATED_VDF_DIR="$(mktemp -d /tmp/mountea-steam-build-XXXXXX)" - local vdf_path="$GENERATED_VDF_DIR/app_build_${STEAM_APP_ID}_linux.vdf" - local build_output_dir="$CONTENT_BUILDER_DIR/output" - - cat >"$vdf_path" <"$setlive_tmp" - mv "$setlive_tmp" "$vdf_path" - fi - - echo "$vdf_path" -} - -cleanup_temp_vdf() { - if [[ -n "$GENERATED_VDF_DIR" ]] && [[ -d "$GENERATED_VDF_DIR" ]]; then - rm -rf "$GENERATED_VDF_DIR" - fi -} - -trap cleanup_temp_vdf EXIT - -echo -echo "[1/3] Building Steam package..." - -if [[ ! -d "$REPO_DIR/node_modules" ]]; then - echo "ERROR: node_modules not found. Run \"npm ci\" in \"$REPO_DIR\" first." >&2 - exit 1 -fi - -( - cd "$REPO_DIR" - # Use native env var assignment on Linux to avoid requiring cross-env. - VITE_DIST_CHANNEL=steam STEAM_APP_ID="$STEAM_APP_ID" npx --no-install vite build - VITE_DIST_CHANNEL=steam STEAM_APP_ID="$STEAM_APP_ID" \ - node scripts/run-electron-builder.mjs \ - --dir \ - --config.extraMetadata.mounteaDistChannel=steam \ - --config.extraMetadata.mounteaSteamAppId="$STEAM_APP_ID" -) - -if ! SRC_DIR="$(find_linux_build_dir)"; then - echo "ERROR: Build output not found under \"$REPO_DIR/release\" (expected linux-unpacked)." >&2 - exit 1 -fi - -echo -echo "[2/3] Copying build to Steam ContentBuilder..." -mkdir -p "$STEAM_CONTENT_DIR" -rsync -rl --delete "$SRC_DIR"/ "$STEAM_CONTENT_DIR"/ - -if ! STEAMCMD_BIN="$(resolve_steamcmd_bin)"; then - echo "ERROR: steamcmd not found. Checked ContentBuilder and PATH." >&2 - exit 1 -fi - -if ! ensure_steamcmd_runtime "$STEAMCMD_BIN"; then - exit 1 -fi - -if [[ -n "$STEAM_APP_BUILD_VDF" ]]; then - if [[ "$STEAM_APP_BUILD_VDF" != /* ]]; then - STEAM_APP_BUILD_VDF="$STEAM_BUILDER_DIR/$STEAM_APP_BUILD_VDF" - fi - if [[ ! -f "$STEAM_APP_BUILD_VDF" ]]; then - echo "ERROR: App build VDF not found: \"$STEAM_APP_BUILD_VDF\"" >&2 - exit 1 - fi -else - if ! STEAM_APP_BUILD_VDF="$(create_temp_app_build_vdf)"; then - exit 1 - fi -fi - -echo -echo "[3/3] Uploading build to Steam depot..." -if [[ "$STEAMCMD_BIN" == *.sh ]]; then - bash "$STEAMCMD_BIN" +login "$STEAM_USERNAME" +run_app_build "$STEAM_APP_BUILD_VDF" +quit -else - STEAMCMD_DIR="$(cd "$(dirname "$STEAMCMD_BIN")" && pwd)" - STEAMCMD_EXE="$(basename "$STEAMCMD_BIN")" - ( - cd "$STEAMCMD_DIR" - "./$STEAMCMD_EXE" +login "$STEAM_USERNAME" +run_app_build "$STEAM_APP_BUILD_VDF" +quit - ) -fi - -echo -echo "SUCCESS: Build packed, copied, and uploaded." -echo "Next: set the new BuildID live on your Steam branch in Steamworks." +exec node "$REPO_DIR/scripts/steam-upload.mjs" --platform linux "$@" diff --git a/steam_build_upload_mac.sh b/steam_build_upload_mac.sh index 784d533..9a6afae 100755 --- a/steam_build_upload_mac.sh +++ b/steam_build_upload_mac.sh @@ -1,226 +1,4 @@ #!/usr/bin/env bash set -euo pipefail - -# Usage: -# ./steam_build_upload_mac.sh -# Optional environment overrides: -# STEAM_SDK_ROOT -# CONTENT_BUILDER_DIR -# STEAM_BUILDER_DIR -# STEAM_CONTENT_DIR -# STEAM_APP_BUILD_VDF -# STEAM_APP_ID -# STEAM_DEPOT_ID_MAC (defaults to 4509322 for this project) -# STEAM_BUILD_DESC -# STEAM_SET_LIVE -# STEAM_PREVIEW (0|1) - REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -if [[ -n "${STEAM_SDK_ROOT:-}" ]]; then - STEAM_SDK_ROOT="$STEAM_SDK_ROOT" -else - for sdk_candidate in \ - "$HOME/Applications/Steam/SDK/sdk" \ - "$HOME/Applications/Steam/SDK" \ - "$HOME/SteamworksSDK" \ - "$HOME/Library/Application Support/Steam/steamapps/common/Steamworks SDK/sdk" \ - "$HOME/Library/Application Support/Steam/steamapps/common/Steamworks SDK"; do - if [[ -d "$sdk_candidate" ]]; then - STEAM_SDK_ROOT="$sdk_candidate" - break - fi - done - STEAM_SDK_ROOT="${STEAM_SDK_ROOT:-$HOME/Applications/Steam/SDK/sdk}" -fi - -if [[ -n "${CONTENT_BUILDER_DIR:-}" ]]; then - CONTENT_BUILDER_DIR="$CONTENT_BUILDER_DIR" -elif [[ -d "$STEAM_SDK_ROOT/tools/ContentBuilder" ]]; then - CONTENT_BUILDER_DIR="$STEAM_SDK_ROOT/tools/ContentBuilder" -elif [[ -d "$STEAM_SDK_ROOT/sdk/tools/ContentBuilder" ]]; then - CONTENT_BUILDER_DIR="$STEAM_SDK_ROOT/sdk/tools/ContentBuilder" -else - CONTENT_BUILDER_DIR="$STEAM_SDK_ROOT/tools/ContentBuilder" -fi - -STEAM_BUILDER_DIR="${STEAM_BUILDER_DIR:-$CONTENT_BUILDER_DIR/builder_osx}" -STEAM_CONTENT_DIR="${STEAM_CONTENT_DIR:-$CONTENT_BUILDER_DIR/content/macos}" -STEAM_APP_BUILD_VDF="${STEAM_APP_BUILD_VDF:-}" -STEAM_APP_ID="${STEAM_APP_ID:-4509320}" -STEAM_DEPOT_ID_MAC="${STEAM_DEPOT_ID_MAC:-4509322}" -STEAM_BUILD_DESC="${STEAM_BUILD_DESC:-macOS build $(date '+%Y-%m-%d %H:%M:%S')}" -STEAM_SET_LIVE="${STEAM_SET_LIVE:-}" -STEAM_PREVIEW="${STEAM_PREVIEW:-0}" -STEAM_USERNAME="semjonprophet" -GENERATED_VDF_DIR="" - -find_mac_build_dir() { - local release_dir="$REPO_DIR/release" - local candidate="" - local candidates=( - "$release_dir/mac-universal" - "$release_dir/mac-arm64" - "$release_dir/mac-x64" - "$release_dir/mac" - ) - - for candidate in "${candidates[@]}"; do - if [[ -d "$candidate" ]] && find "$candidate" -maxdepth 1 -type d -name "*.app" | grep -q .; then - echo "$candidate" - return 0 - fi - done - - return 1 -} - -resolve_steamcmd_bin() { - local candidates=( - "$STEAM_BUILDER_DIR/steamcmd.sh" - "$STEAM_BUILDER_DIR/steamcmd" - "$CONTENT_BUILDER_DIR/builder_osx/steamcmd.sh" - "$CONTENT_BUILDER_DIR/builder/steamcmd.sh" - ) - local cmd="" - - for cmd in "${candidates[@]}"; do - if [[ -f "$cmd" ]] && [[ ! -x "$cmd" ]]; then - chmod +x "$cmd" >/dev/null 2>&1 || true - fi - if [[ -x "$cmd" ]]; then - echo "$cmd" - return 0 - fi - done - - if command -v steamcmd >/dev/null 2>&1; then - command -v steamcmd - return 0 - fi - - return 1 -} - -create_temp_app_build_vdf() { - if [[ -z "$STEAM_DEPOT_ID_MAC" ]]; then - echo "ERROR: STEAM_DEPOT_ID_MAC is required when STEAM_APP_BUILD_VDF is not provided." >&2 - return 1 - fi - - if [[ ! "$STEAM_APP_ID" =~ ^[0-9]+$ ]]; then - echo "ERROR: STEAM_APP_ID must be numeric (current: \"$STEAM_APP_ID\")." >&2 - return 1 - fi - - if [[ ! "$STEAM_DEPOT_ID_MAC" =~ ^[0-9]+$ ]]; then - echo "ERROR: STEAM_DEPOT_ID_MAC must be numeric (current: \"$STEAM_DEPOT_ID_MAC\")." >&2 - return 1 - fi - - if [[ ! "$STEAM_PREVIEW" =~ ^[01]$ ]]; then - echo "ERROR: STEAM_PREVIEW must be 0 or 1 (current: \"$STEAM_PREVIEW\")." >&2 - return 1 - fi - - GENERATED_VDF_DIR="$(mktemp -d /tmp/mountea-steam-build-XXXXXX)" - local vdf_path="$GENERATED_VDF_DIR/app_build_${STEAM_APP_ID}_mac.vdf" - local build_output_dir="$CONTENT_BUILDER_DIR/output" - - cat >"$vdf_path" <"$setlive_tmp" - mv "$setlive_tmp" "$vdf_path" - fi - - echo "$vdf_path" -} - -cleanup_temp_vdf() { - if [[ -n "$GENERATED_VDF_DIR" ]] && [[ -d "$GENERATED_VDF_DIR" ]]; then - rm -rf "$GENERATED_VDF_DIR" - fi -} - -trap cleanup_temp_vdf EXIT - -echo -echo "[1/3] Building Steam package..." -( - cd "$REPO_DIR" - npm run electron:pack:steam -) - -if ! SRC_DIR="$(find_mac_build_dir)"; then - echo "ERROR: Build output not found under \"$REPO_DIR/release\" (expected mac*/.app)." >&2 - exit 1 -fi - -echo -echo "[2/3] Copying build to Steam ContentBuilder..." -mkdir -p "$STEAM_CONTENT_DIR" -# Mirror content similarly to robocopy /MIR but avoid macOS metadata/mtime permission issues. -rsync -rl --delete "$SRC_DIR"/ "$STEAM_CONTENT_DIR"/ - -if ! STEAMCMD_BIN="$(resolve_steamcmd_bin)"; then - echo "ERROR: steamcmd not found. Checked ContentBuilder and PATH." >&2 - exit 1 -fi - -if [[ -n "$STEAM_APP_BUILD_VDF" ]]; then - if [[ "$STEAM_APP_BUILD_VDF" != /* ]]; then - STEAM_APP_BUILD_VDF="$STEAM_BUILDER_DIR/$STEAM_APP_BUILD_VDF" - fi - if [[ ! -f "$STEAM_APP_BUILD_VDF" ]]; then - echo "ERROR: App build VDF not found: \"$STEAM_APP_BUILD_VDF\"" >&2 - exit 1 - fi -else - if ! STEAM_APP_BUILD_VDF="$(create_temp_app_build_vdf)"; then - exit 1 - fi -fi - -echo -echo "[3/3] Uploading build to Steam depot..." -STEAMCMD_DIR="$(cd "$(dirname "$STEAMCMD_BIN")" && pwd)" -STEAMCMD_EXE="$(basename "$STEAMCMD_BIN")" -( - cd "$STEAMCMD_DIR" - "./$STEAMCMD_EXE" +login "$STEAM_USERNAME" +run_app_build "$STEAM_APP_BUILD_VDF" +quit -) - -echo -echo "SUCCESS: Build packed, copied, and uploaded." -echo "Next: set the new BuildID live on your Steam branch in Steamworks." +exec node "$REPO_DIR/scripts/steam-upload.mjs" --platform macos "$@" From e7c3ec89b25e0450fed6d02585a6484cfe11bfeb Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:12 +0200 Subject: [PATCH 04/22] A: Add Electron security and credential modules Add credential, OAuth session, diagnostics, artifact integrity and Steam file modules; wire them into main, preload, steam and sentry. --- electron/artifact-integrity.cjs | 21 + electron/credentials.cjs | 40 ++ electron/diagnostics.cjs | 20 + electron/main.cjs | 660 +++++--------------------------- electron/oauth-session.cjs | 18 + electron/preload.cjs | 5 + electron/security.cjs | 110 ++++++ electron/sentry.cjs | 17 +- electron/steam-files.cjs | 79 ++++ electron/steam.cjs | 18 +- 10 files changed, 416 insertions(+), 572 deletions(-) create mode 100644 electron/artifact-integrity.cjs create mode 100644 electron/credentials.cjs create mode 100644 electron/diagnostics.cjs create mode 100644 electron/oauth-session.cjs create mode 100644 electron/security.cjs create mode 100644 electron/steam-files.cjs diff --git a/electron/artifact-integrity.cjs b/electron/artifact-integrity.cjs new file mode 100644 index 0000000..f6f8f60 --- /dev/null +++ b/electron/artifact-integrity.cjs @@ -0,0 +1,21 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); + +// Electron's patched fs also reads files inside app.asar. Include the validation +// records themselves so the installed renderer must match every validated byte. +function fingerprintDirectory(directory) { + const files = {}; + function visit(root, prefix = '') { + for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const name = prefix + entry.name, location = path.join(root, entry.name); + if (entry.isSymbolicLink()) throw new Error(`Packaged renderer contains a symbolic link: ${name}`); + if (entry.isDirectory()) visit(location, `${name}/`); + else if (entry.isFile()) files[name] = createHash('sha256').update(fs.readFileSync(location)).digest('hex'); + else throw new Error(`Unsupported renderer entry: ${name}`); + } + } + visit(directory); + return files; +} +module.exports = { fingerprintDirectory }; diff --git a/electron/credentials.cjs b/electron/credentials.cjs new file mode 100644 index 0000000..9b4b712 --- /dev/null +++ b/electron/credentials.cjs @@ -0,0 +1,40 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +function createCredentialVault({ safeStorage, directory, platform = process.platform }) { + let pending = Promise.resolve(); + const file = path.join(directory, 'credentials.v1.json'); + const available = () => Boolean(safeStorage.isEncryptionAvailable()) && (platform !== 'linux' || (typeof safeStorage.getSelectedStorageBackend === 'function' && safeStorage.getSelectedStorageBackend() !== 'basic_text')); + const read = async () => { + try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return {}; throw error; } + }; + const serialize = (action) => { + const result = pending.then(action, action); + pending = result.catch(() => {}); + return result; + }; + const write = async (records) => { + await fs.mkdir(directory, { recursive: true }); + const temporary = `${file}.${crypto.randomUUID()}.tmp`; + try { await fs.writeFile(temporary, JSON.stringify(records), { mode: 0o600 }); await fs.rename(temporary, file); } + finally { await fs.rm(temporary, { force: true }); } + }; + return { + status: () => ({ canRemember: available() }), + get: (profileId, key) => serialize(async () => { + if (!available()) return null; + const value = (await read())[`${profileId}:${key}`]; + return value ? safeStorage.decryptString(Buffer.from(value, 'base64')) : null; + }), + set: (profileId, key, value) => serialize(async () => { + if (!available()) throw new Error('Secure credential storage is unavailable; remembering is disabled'); + const records = await read(); + records[`${profileId}:${key}`] = safeStorage.encryptString(value).toString('base64'); + await write(records); + return true; + }), + remove: (profileId, key) => serialize(async () => { const records = await read(); delete records[`${profileId}:${key}`]; await write(records); return true; }), + }; +} +module.exports = { createCredentialVault }; diff --git a/electron/diagnostics.cjs b/electron/diagnostics.cjs new file mode 100644 index 0000000..e031dc7 --- /dev/null +++ b/electron/diagnostics.cjs @@ -0,0 +1,20 @@ +const fs = require('node:fs'); + +function sanitizeDiagnostics(value, depth = 0) { + if (depth > 4) return '[depth limit]'; + if (Array.isArray(value)) return value.slice(0, 25).map((entry) => sanitizeDiagnostics(entry, depth + 1)); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).slice(0, 40).map(([key, entry]) => [key, /token|secret|passphrase|password|authorization|content|snapshot|email|name|title|description|text|url|path/i.test(key) ? '[redacted]' : sanitizeDiagnostics(entry, depth + 1)])); + // Provider errors are arbitrary prose and may echo credentials or authored + // content. Retain only numbers/booleans and the separately validated event ID. + return typeof value === 'string' ? '[redacted]' : value; +} +function appendDiagnostic(file, event, details) { + const line = `${new Date().toISOString()} ${String(event).replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 80)} ${JSON.stringify(sanitizeDiagnostics(details))}\n`; + if (fs.existsSync(file) && fs.statSync(file).size + Buffer.byteLength(line) > 5 * 1024 * 1024) { + fs.rmSync(`${file}.3`, { force: true }); + for (let index = 2; index >= 1; index--) if (fs.existsSync(`${file}.${index}`)) fs.renameSync(`${file}.${index}`, `${file}.${index + 1}`); + fs.renameSync(file, `${file}.1`); + } + fs.appendFileSync(file, line, { encoding: 'utf8', mode: 0o600 }); +} +module.exports = { sanitizeDiagnostics, appendDiagnostic }; diff --git a/electron/main.cjs b/electron/main.cjs index 36e60ee..df5bfa5 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -3,7 +3,7 @@ const fs = require('node:fs'); const http = require('node:http'); const crypto = require('node:crypto'); const { URL, URLSearchParams } = require('node:url'); -const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron'); +const { app, BrowserWindow, Menu, dialog, ipcMain, shell, safeStorage } = require('electron'); const { initializeSteamRuntime, prepareSteamOverlayForElectron, @@ -12,7 +12,7 @@ const { setRichPresence: setSteamRichPresence, unlockAchievement: unlockSteamAchievement, getSteamCloudStatus, - steamCloudFileExists, + listSteamCloudFileNames, readSteamCloudFile, writeSteamCloudFile, shutdownSteamRuntime, @@ -22,6 +22,17 @@ const { captureMainProcessException, } = require('./sentry.cjs'); +const { createTrustedIpc, isTrustedRendererUrl } = require('./security.cjs'); +const { createCredentialVault } = require('./credentials.cjs'); +const { createSteamFileTransport } = require('./steam-files.cjs'); +const { appendDiagnostic, sanitizeDiagnostics } = require('./diagnostics.cjs'); +let credentialVault; +function getCredentialVault() { + if (!credentialVault) credentialVault = createCredentialVault({ safeStorage, directory: app.getPath('userData') }); + return credentialVault; +} +function rendererPolicy() { return { distIndexPath: getDistIndexPath(), devServerUrl: process.env.VITE_DEV_SERVER_URL, isPackaged: app.isPackaged }; } + const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'; const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'; const LOOPBACK_HOST = '127.0.0.1'; @@ -35,10 +46,6 @@ const SUPPORT_URL = 'https://discord.gg/hCjh8e3Y9r'; const ISSUES_URL = 'https://github.com/Mountea-Framework/MounteaDialoguer/issues'; const APP_DISPLAY_NAME = 'Mountea Dialoguer'; const STEAM_SYNC_ROOT_FOLDER = 'steam-sync'; -const STEAM_SYNC_FILE_EXTENSION = '.json'; -const STEAM_SYNC_CLOUD_FILE_PREFIX = 'steam-sync__'; -const STEAM_SYNC_BUNDLE_FILE_BASENAME = 'bundle'; -const STEAM_SYNC_BUNDLE_CACHE_FILE_NAME = `${STEAM_SYNC_BUNDLE_FILE_BASENAME}${STEAM_SYNC_FILE_EXTENSION}`; let mainWindow = null; const DEFAULT_MENU_CONTEXT = Object.freeze({ @@ -346,11 +353,7 @@ function isAllowedExternalUrl(rawUrl) { } function isInternalNavigation(url) { - const devServerUrl = process.env.VITE_DEV_SERVER_URL; - if (devServerUrl && url.startsWith(devServerUrl)) { - return true; - } - return url.startsWith('file://'); + return isTrustedRendererUrl(url, rendererPolicy()); } function shouldBlockNativeShortcut(input = {}) { @@ -380,7 +383,8 @@ function shouldBlockNativeShortcut(input = {}) { function sanitizeFileNameForSaveDialog(fileName, fallback = 'export.bin') { const normalized = String(fileName || '') .trim() - .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_'); + .replace(/[<>:"/\\|?*]/g, '_') + .split('').map((character) => character.charCodeAt(0) < 32 ? '_' : character).join(''); return normalized || fallback; } @@ -482,14 +486,14 @@ function getSteamSyncProfileDirectory(profileId) { function logSteamSyncEvent(eventName, details = {}) { const safeEvent = String(eventName || 'event'); - const safeDetails = details && typeof details === 'object' ? details : {}; + const safeDetails = sanitizeDiagnostics(details && typeof details === 'object' ? details : {}); const timestamp = new Date().toISOString(); const logLine = `[${timestamp}] [steam-sync] ${safeEvent} ${JSON.stringify(safeDetails)}`; console.log(logLine); try { if (app.isReady()) { const diagnosticsPath = path.join(app.getPath('userData'), 'steam-sync-diagnostics.log'); - fs.appendFileSync(diagnosticsPath, `${logLine}\n`, 'utf8'); + appendDiagnostic(diagnosticsPath, safeEvent, safeDetails); } } catch (error) { // Best-effort diagnostics logging only. @@ -510,506 +514,38 @@ async function ensureSteamSyncProfileDirectory(profileId) { } async function ensureSteamSyncDirectoriesForRuntime(runtimeState = steamRuntimeState) { - const userDataPath = app.getPath('userData'); - const rootDir = await ensureSteamSyncRootDirectory(); - const cloudStatus = getSteamCloudRuntimeStatus(); - logSteamSyncEvent('ROOT_READY', { - userDataPath, - rootDir, - cloud: cloudStatus, - }); - - const steamAvailable = Boolean(runtimeState?.available); - const steamId = String(runtimeState?.steamId || '').trim(); - if (!steamAvailable || !steamId) return; - - const profileId = sanitizeSteamSyncSegment(`steam-${steamId}`, 'local'); - const profileDir = await ensureSteamSyncProfileDirectory(profileId); - logSteamSyncEvent('PROFILE_READY', { profileId, profileDir }); - - // Seed local bundle cache immediately so Steam Auto-Cloud has a concrete file - // even before the first project write in this session. - const { bundle, source } = await readSteamSyncBundle(profileId); - let cloudWritten = false; - if (source !== 'steam-cloud') { - cloudWritten = writeSteamSyncBundleToCloud(profileId, bundle); - } - logSteamSyncEvent('PROFILE_SYNC_READY', { - profileId, - source, - entryCount: Array.isArray(bundle?.entries) ? bundle.entries.length : 0, - cloudWritten, - cloud: getSteamCloudRuntimeStatus(), - }); -} - -function getSteamSyncBundleCachePath(profileId) { - return path.join( - getSteamSyncProfileDirectory(profileId), - STEAM_SYNC_BUNDLE_CACHE_FILE_NAME - ); -} - -function getSteamSyncBundleCloudFileName(profileId) { - const safeProfileId = sanitizeSteamSyncSegment(profileId, 'local'); - return `${STEAM_SYNC_CLOUD_FILE_PREFIX}${safeProfileId}__${STEAM_SYNC_BUNDLE_FILE_BASENAME}${STEAM_SYNC_FILE_EXTENSION}`; -} - -function getSteamCloudRuntimeStatus() { - const status = getSteamCloudStatus(); - const enabled = Boolean( - status?.available && status?.enabledForApp && status?.enabledForAccount - ); - return { - available: Boolean(status?.available), - enabledForApp: Boolean(status?.enabledForApp), - enabledForAccount: Boolean(status?.enabledForAccount), - enabled, - error: String(status?.error || ''), - }; -} - -function normalizeSteamSyncAppProperties(rawValue) { - if (!rawValue || typeof rawValue !== 'object') return {}; - const normalized = {}; - for (const [key, value] of Object.entries(rawValue)) { - const safeKey = String(key || '').trim(); - if (!safeKey) continue; - normalized[safeKey] = String(value ?? ''); - } - return normalized; -} - -function normalizeSteamSyncEntry(rawEntry, fallbackId = '') { - const id = sanitizeSteamSyncSegment(rawEntry?.id || fallbackId || crypto.randomUUID(), 'entry'); - const name = String(rawEntry?.name || '').trim(); - const parsedModified = Date.parse(String(rawEntry?.modifiedTime || '')); - const modifiedTime = Number.isFinite(parsedModified) - ? new Date(parsedModified).toISOString() - : new Date().toISOString(); - - return { - id, - name, - content: typeof rawEntry?.content === 'string' ? rawEntry.content : '', - mimeType: String(rawEntry?.mimeType || 'application/json'), - appProperties: normalizeSteamSyncAppProperties(rawEntry?.appProperties), - modifiedTime, - }; -} - -function createEmptySteamSyncBundle(profileId) { - return { - schemaVersion: 1, - profileId: sanitizeSteamSyncSegment(profileId, 'local'), - modifiedTime: new Date().toISOString(), - entries: [], - }; -} - -function normalizeSteamSyncBundle(rawBundle, profileId) { - const fallbackProfileId = sanitizeSteamSyncSegment(profileId, 'local'); - const rawEntries = Array.isArray(rawBundle?.entries) ? rawBundle.entries : []; - const deduped = new Map(); - - for (const rawEntry of rawEntries) { - const normalized = normalizeSteamSyncEntry(rawEntry, rawEntry?.id || ''); - if (!normalized.name) continue; - const existing = deduped.get(normalized.id); - if (!existing) { - deduped.set(normalized.id, normalized); - continue; - } - const existingTs = Date.parse(existing.modifiedTime || '') || 0; - const normalizedTs = Date.parse(normalized.modifiedTime || '') || 0; - if (normalizedTs >= existingTs) { - deduped.set(normalized.id, normalized); - } - } - - const entries = Array.from(deduped.values()).sort( - (a, b) => Date.parse(b.modifiedTime) - Date.parse(a.modifiedTime) - ); - return { - schemaVersion: Number(rawBundle?.schemaVersion || 1), - profileId: sanitizeSteamSyncSegment(rawBundle?.profileId || fallbackProfileId, fallbackProfileId), - modifiedTime: new Date().toISOString(), - entries, - }; -} - -async function readLegacySteamSyncEntriesFromLocal(profileId) { - const profileDir = await ensureSteamSyncProfileDirectory(profileId); - const dirItems = await fs.promises.readdir(profileDir, { withFileTypes: true }); - const entries = []; - - for (const item of dirItems) { - if (!item.isFile()) continue; - if (!item.name.endsWith(STEAM_SYNC_FILE_EXTENSION)) continue; - if (item.name === STEAM_SYNC_BUNDLE_CACHE_FILE_NAME) continue; - - const fileId = item.name.slice(0, -STEAM_SYNC_FILE_EXTENSION.length); - const filePath = path.join(profileDir, item.name); - try { - const content = await fs.promises.readFile(filePath, 'utf8'); - const parsed = JSON.parse(content); - const entry = normalizeSteamSyncEntry(parsed, fileId); - if (!entry.name) continue; - entries.push(entry); - } catch (error) { - console.warn('[steam-sync] Failed to parse legacy entry:', filePath, error?.message || error); - } - } - - return entries; -} - -async function cleanupLegacySteamSyncFiles(profileId) { - const profileDir = await ensureSteamSyncProfileDirectory(profileId); - const dirItems = await fs.promises.readdir(profileDir, { withFileTypes: true }); - for (const item of dirItems) { - if (!item.isFile()) continue; - if (!item.name.endsWith(STEAM_SYNC_FILE_EXTENSION)) continue; - if (item.name === STEAM_SYNC_BUNDLE_CACHE_FILE_NAME) continue; - const fullPath = path.join(profileDir, item.name); - try { - await fs.promises.unlink(fullPath); - } catch (error) { - // Best-effort cleanup only. - } - } -} - -async function readSteamSyncBundleFromLocal(profileId) { - const bundlePath = getSteamSyncBundleCachePath(profileId); - if (fs.existsSync(bundlePath)) { - try { - const content = await fs.promises.readFile(bundlePath, 'utf8'); - return normalizeSteamSyncBundle(JSON.parse(content), profileId); - } catch (error) { - console.warn('[steam-sync] Failed to parse bundle cache:', bundlePath, error?.message || error); - } - } - - const legacyEntries = await readLegacySteamSyncEntriesFromLocal(profileId); - if (legacyEntries.length === 0) { - const seededBundle = await writeSteamSyncBundleToLocal( - profileId, - createEmptySteamSyncBundle(profileId), - { skipCleanup: true } - ); - logSteamSyncEvent('BUNDLE_LOCAL_SEEDED', { - profileId, - bundlePath: getSteamSyncBundleCachePath(profileId), - }); - return seededBundle; - } - - const migrated = normalizeSteamSyncBundle( - { - schemaVersion: 1, - profileId, - entries: legacyEntries, - }, - profileId - ); - await writeSteamSyncBundleToLocal(profileId, migrated, { skipCleanup: true }); - await cleanupLegacySteamSyncFiles(profileId); - logSteamSyncEvent('BUNDLE_MIGRATED', { - profileId, - entryCount: migrated.entries.length, - }); - return migrated; -} - -function readSteamSyncBundleFromCloud(profileId) { - const cloudStatus = getSteamCloudRuntimeStatus(); - if (!cloudStatus.enabled) return null; - - const storageName = getSteamSyncBundleCloudFileName(profileId); - if (!steamCloudFileExists(storageName)) return null; - - try { - const rawContent = readSteamCloudFile(storageName); - return normalizeSteamSyncBundle(JSON.parse(rawContent), profileId); - } catch (error) { - console.warn('[steam-sync] Failed to parse cloud bundle:', error?.message || error); - return null; - } -} - -async function writeSteamSyncBundleToLocal(profileId, bundle, options = {}) { - const normalized = normalizeSteamSyncBundle(bundle, profileId); - await ensureSteamSyncProfileDirectory(profileId); - const bundlePath = getSteamSyncBundleCachePath(profileId); - await fs.promises.writeFile(bundlePath, JSON.stringify(normalized, null, 2), 'utf8'); - if (!options?.skipCleanup) { - await cleanupLegacySteamSyncFiles(profileId); - } - return normalized; -} - -function writeSteamSyncBundleToCloud(profileId, bundle) { - const cloudStatus = getSteamCloudRuntimeStatus(); - if (!cloudStatus.enabled) return false; - const storageName = getSteamSyncBundleCloudFileName(profileId); - const normalized = normalizeSteamSyncBundle(bundle, profileId); - return writeSteamCloudFile(storageName, JSON.stringify(normalized)); -} - -async function readSteamSyncBundle(profileId) { - const cloudBundle = readSteamSyncBundleFromCloud(profileId); - if (cloudBundle) { - await writeSteamSyncBundleToLocal(profileId, cloudBundle); - return { bundle: cloudBundle, source: 'steam-cloud' }; - } - - const localBundle = await readSteamSyncBundleFromLocal(profileId); - return { bundle: localBundle, source: 'local-cache' }; -} - -async function writeSteamSyncBundle(profileId, bundle) { - const normalized = await writeSteamSyncBundleToLocal(profileId, bundle); - const cloudWritten = writeSteamSyncBundleToCloud(profileId, normalized); - return { bundle: normalized, cloudWritten }; -} - -function toSteamSyncFileMetadata(entry) { - return { - id: entry.id, - name: entry.name, - modifiedTime: entry.modifiedTime, - appProperties: entry.appProperties, - }; -} - -async function listSteamSyncEntries(profileId) { - const { bundle, source } = await readSteamSyncBundle(profileId); - const entries = Array.isArray(bundle?.entries) ? bundle.entries : []; - logSteamSyncEvent('LIST_SOURCE', { - profileId, - source, - count: entries.length, - }); - return entries; -} - -async function steamSyncFindFile(payload = {}) { - const profileId = sanitizeSteamSyncSegment(payload?.profileId, 'local'); - const fileName = String(payload?.fileName || '').trim(); - if (!fileName) return null; - - const entries = await listSteamSyncEntries(profileId); - const match = entries.find((entry) => entry.name === fileName); - if (!match) return null; - logSteamSyncEvent('FIND', { profileId, fileName, found: true, fileId: match.id }); - return toSteamSyncFileMetadata(match); -} - -async function steamSyncListFiles(payload = {}) { - const profileId = sanitizeSteamSyncSegment(payload?.profileId, 'local'); - const namePrefix = String(payload?.namePrefix || '').trim(); - const entries = await listSteamSyncEntries(profileId); - const mapped = entries - .filter((entry) => !namePrefix || entry.name.includes(namePrefix)) - .map((entry) => toSteamSyncFileMetadata(entry)); - logSteamSyncEvent('LIST', { - profileId, - namePrefix, - count: mapped.length, - directory: getSteamSyncProfileDirectory(profileId), - }); - return mapped; -} - -async function steamSyncDownloadFile(payload = {}) { - const profileId = sanitizeSteamSyncSegment(payload?.profileId, 'local'); - const fileId = sanitizeSteamSyncSegment(payload?.fileId, ''); - if (!fileId) { - throw new Error('Missing Steam sync file id'); - } - - const { bundle, source } = await readSteamSyncBundle(profileId); - const entry = (bundle?.entries || []).find((item) => item.id === fileId); - if (!entry) { - throw new Error(`Steam sync file not found: ${fileId}`); - } - logSteamSyncEvent('DOWNLOAD', { - profileId, - fileId, - name: entry.name, - source, - }); - return entry.content; -} - -async function steamSyncCreateFile(payload = {}) { - const profileId = sanitizeSteamSyncSegment(payload?.profileId, 'local'); - const name = String(payload?.name || '').trim(); - if (!name) { - throw new Error('Missing Steam sync file name'); - } - - const entry = normalizeSteamSyncEntry( - { - id: crypto.randomUUID(), - name, - content: String(payload?.content || ''), - mimeType: payload?.mimeType || 'application/json', - appProperties: payload?.appProperties || {}, - modifiedTime: new Date().toISOString(), - }, - '' - ); - - const { bundle } = await readSteamSyncBundle(profileId); - const nextBundle = normalizeSteamSyncBundle( - { - ...bundle, - profileId, - modifiedTime: new Date().toISOString(), - entries: [...(bundle?.entries || []), entry], - }, - profileId - ); - - const { cloudWritten } = await writeSteamSyncBundle(profileId, nextBundle); - if (!cloudWritten) { - logSteamSyncEvent('CREATE_CLOUD_SKIPPED', { - profileId, - fileId: entry.id, - name: entry.name, - cloud: getSteamCloudRuntimeStatus(), - }); - const cloud = getSteamCloudRuntimeStatus(); - const cloudError = String(cloud?.error || '').trim(); - throw new Error( - `Steam Cloud write skipped during create: ${cloudError || 'cloud unavailable'}` - ); - } - - logSteamSyncEvent('CREATE', { - profileId, - fileId: entry.id, - name: entry.name, - bundlePath: getSteamSyncBundleCachePath(profileId), - cloudWritten, - }); - return { - ...toSteamSyncFileMetadata(entry), - cloudWritten, - }; -} - -async function steamSyncUpdateFile(payload = {}) { - const profileId = sanitizeSteamSyncSegment(payload?.profileId, 'local'); - const fileId = sanitizeSteamSyncSegment(payload?.fileId, ''); - if (!fileId) { - throw new Error('Missing Steam sync file id'); - } - - const { bundle } = await readSteamSyncBundle(profileId); - const existing = (bundle?.entries || []).find((item) => item.id === fileId); - if (!existing) { - throw new Error(`Steam sync file not found: ${fileId}`); - } - const updated = normalizeSteamSyncEntry( - { - ...existing, - content: String(payload?.content || ''), - mimeType: payload?.mimeType || existing.mimeType || 'application/json', - appProperties: payload?.appProperties || existing.appProperties || {}, - modifiedTime: new Date().toISOString(), - }, - fileId - ); - - const nextEntries = (bundle?.entries || []).map((item) => - item.id === fileId ? updated : item - ); - const nextBundle = normalizeSteamSyncBundle( - { - ...bundle, - profileId, - modifiedTime: new Date().toISOString(), - entries: nextEntries, - }, - profileId - ); - const { cloudWritten } = await writeSteamSyncBundle(profileId, nextBundle); - if (!cloudWritten) { - logSteamSyncEvent('UPDATE_CLOUD_SKIPPED', { - profileId, - fileId: updated.id, - name: updated.name, - cloud: getSteamCloudRuntimeStatus(), - }); - const cloud = getSteamCloudRuntimeStatus(); - const cloudError = String(cloud?.error || '').trim(); - throw new Error( - `Steam Cloud write skipped during update: ${cloudError || 'cloud unavailable'}` - ); - } - logSteamSyncEvent('UPDATE', { - profileId, - fileId: updated.id, - name: updated.name, - bundlePath: getSteamSyncBundleCachePath(profileId), - cloudWritten, - }); - return { - ...toSteamSyncFileMetadata(updated), - cloudWritten, - }; -} - -async function steamSyncDeleteFile(payload = {}) { - const profileId = sanitizeSteamSyncSegment(payload?.profileId, 'local'); - const fileId = sanitizeSteamSyncSegment(payload?.fileId, ''); - if (!fileId) { - throw new Error('Missing Steam sync file id'); - } - - const { bundle } = await readSteamSyncBundle(profileId); - const existing = (bundle?.entries || []).find((item) => item.id === fileId); - if (!existing) { - return { id: fileId, deleted: false }; - } - - const nextEntries = (bundle?.entries || []).filter((item) => item.id !== fileId); - const nextBundle = normalizeSteamSyncBundle( - { - ...bundle, - profileId, - modifiedTime: new Date().toISOString(), - entries: nextEntries, + await ensureSteamSyncRootDirectory(); + if (runtimeState?.available && runtimeState?.steamId) await ensureSteamSyncProfileDirectory(`steam-${runtimeState.steamId}`); +} +let steamFileTransport; +function getSteamFileTransport() { + if (!steamFileTransport) steamFileTransport = createSteamFileTransport({ + directory: getSteamSyncRootDirectory(), + cloud: { status: getSteamCloudStatus, list: listSteamCloudFileNames, read: readSteamCloudFile, write: writeSteamCloudFile }, + readLegacy: async (profileId) => { + const legacyName = `steam-sync__${profileId}__bundle.json`; + const names = listSteamCloudFileNames(); + const entries = []; + if (names.includes(legacyName)) entries.push(...(JSON.parse(readSteamCloudFile(legacyName)).entries || [])); + const folder = getSteamSyncProfileDirectory(profileId); + try { + for (const file of await fs.promises.readdir(folder)) { + if (!file.endsWith('.json')) continue; + const data = JSON.parse(await fs.promises.readFile(path.join(folder, file), 'utf8')); + entries.push(...(file === 'bundle.json' ? data.entries || [] : [data])); + } + } catch (error) { if (error.code !== 'ENOENT') throw error; } + return [...new Map(entries.filter((entry) => entry.id && entry.name).map((entry) => [entry.id, entry])).values()]; }, - profileId - ); - const { cloudWritten } = await writeSteamSyncBundle(profileId, nextBundle); - if (!cloudWritten) { - logSteamSyncEvent('DELETE_CLOUD_SKIPPED', { - profileId, - fileId: existing.id, - name: existing.name, - cloud: getSteamCloudRuntimeStatus(), - }); - const cloud = getSteamCloudRuntimeStatus(); - const cloudError = String(cloud?.error || '').trim(); - throw new Error( - `Steam Cloud write skipped during delete: ${cloudError || 'cloud unavailable'}` - ); - } - logSteamSyncEvent('DELETE', { - profileId, - fileId: existing.id, - name: existing.name, - bundlePath: getSteamSyncBundleCachePath(profileId), - cloudWritten, }); - return { id: fileId, deleted: true, cloudWritten }; -} - + return steamFileTransport; +} +const steamSyncFindFile = (payload) => getSteamFileTransport().find(payload); +const steamSyncListFiles = (payload) => getSteamFileTransport().list(payload); +const steamSyncDownloadFile = (payload) => getSteamFileTransport().download(payload); +const steamSyncCreateFile = (payload) => getSteamFileTransport().create(payload); +const steamSyncUpdateFile = (payload) => getSteamFileTransport().update(payload); +const steamSyncDeleteFile = (payload) => getSteamFileTransport().remove(payload); function sendMenuCommand(command, payload = {}) { const targetWindow = BrowserWindow.getFocusedWindow() || mainWindow; if (!targetWindow || targetWindow.isDestroyed()) return; @@ -1295,7 +831,7 @@ function createAppMenu(context = menuContext) { accelerator: 'CmdOrCtrl+,', click: () => sendMenuCommand('open-settings'), }, - ] + ] : []), ...(canOpenSync ? [ @@ -1304,7 +840,7 @@ function createAppMenu(context = menuContext) { accelerator: 'CmdOrCtrl+Shift+S', click: () => sendMenuCommand('open-sync'), }, - ] + ] : []), ]); @@ -1353,7 +889,7 @@ function createAppMenu(context = menuContext) { { role: 'quit' }, ], }, - ] + ] : []), { label: 'File', @@ -1477,6 +1013,7 @@ async function exchangeCodeForToken({ redirectUri, codeVerifier, clientSecret, + signal, }) { const body = new URLSearchParams({ client_id: clientId, @@ -1491,6 +1028,7 @@ async function exchangeCodeForToken({ } const response = await fetch(TOKEN_ENDPOINT, { + signal, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -1521,11 +1059,13 @@ async function exchangeCodeForToken({ return payload || {}; } +const oauthSession = require('./oauth-session.cjs').createOAuthSession(); async function executeOAuthAttempt({ clientId, clientSecret, scopes, responseType, + signal, }) { console.log(`[oauth] Starting Google OAuth attempt (${responseType})`); const oauthStartedAt = Date.now(); @@ -1545,6 +1085,7 @@ async function executeOAuthAttempt({ let timeoutHandle = null; let rejectAuthResult = null; const callbackSockets = new Set(); + const cancelAttempt = () => rejectAuthResult(signal.reason); const authResultPromise = new Promise((resolve, reject) => { let settled = false; @@ -1554,6 +1095,7 @@ async function executeOAuthAttempt({ handler(value); }; rejectAuthResult = (reason) => settle(reject, reason); + signal.addEventListener('abort', cancelAttempt, { once: true }); server = http.createServer((req, res) => { res.setHeader('Connection', 'close'); @@ -1668,12 +1210,16 @@ async function executeOAuthAttempt({ }); }); + // Attach immediately: cancellation can precede the listen callback. + authResultPromise.catch(() => {}); try { + signal.throwIfAborted(); await new Promise((resolve, reject) => { server.listen(0, LOOPBACK_HOST, resolve); server.once('error', reject); }); + signal.throwIfAborted(); const address = server.address(); const port = typeof address === 'object' && address ? address.port : null; if (!port) { @@ -1746,7 +1292,9 @@ async function executeOAuthAttempt({ redirectUri, codeVerifier, clientSecret, + signal, }); + signal.throwIfAborted(); console.log( `[oauth] Token exchange completed in ${Date.now() - tokenExchangeStartedAt}ms` ); @@ -1766,6 +1314,7 @@ async function executeOAuthAttempt({ clientId: trimmedClientId, }; } finally { + signal.removeEventListener('abort', cancelAttempt); if (timeoutHandle) { clearTimeout(timeoutHandle); } @@ -1799,7 +1348,7 @@ async function executeOAuthAttempt({ } } -async function startGoogleOAuth({ clientId, clientSecret, scopes }) { +async function startGoogleOAuth({ clientId, clientSecret, scopes }, signal) { const resolvedClientId = resolveDesktopOAuthClientId(clientId); const resolvedClientSecret = resolveDesktopOAuthClientSecret(clientSecret); console.log( @@ -1814,8 +1363,10 @@ async function startGoogleOAuth({ clientId, clientSecret, scopes }) { clientSecret: resolvedClientSecret, scopes, responseType: 'code', + signal, }); } catch (error) { + signal.throwIfAborted(); const message = String(error?.message || '').toLowerCase(); const allowImplicitFallback = process.env.MOUNTEA_OAUTH_ALLOW_IMPLICIT === '1'; const isRecoverableCodeFlowError = @@ -1839,6 +1390,7 @@ async function startGoogleOAuth({ clientId, clientSecret, scopes }) { clientSecret: '', scopes, responseType: 'token', + signal, }); } } @@ -1861,7 +1413,7 @@ function createMainWindow() { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, - sandbox: false, + sandbox: true, }, }); @@ -1872,15 +1424,17 @@ function createMainWindow() { return { action: 'deny' }; }); - mainWindow.webContents.on('will-navigate', (event, url) => { + const guardNavigation = (event, url) => { if (isInternalNavigation(url)) { return; } + event.preventDefault(); if (isAllowedExternalUrl(url)) { - event.preventDefault(); shell.openExternal(url); } - }); + }; + mainWindow.webContents.on('will-navigate', guardNavigation); + mainWindow.webContents.on('will-redirect', guardNavigation); mainWindow.webContents.on('before-input-event', (event, input) => { if (shouldBlockNativeShortcut(input)) { @@ -1903,10 +1457,10 @@ function createMainWindow() { }); mainWindow.once('ready-to-show', () => { - mainWindow.show(); + if (process.env.MOUNTEA_STARTUP_CHECK !== '1') mainWindow.show(); }); - const devServerUrl = process.env.VITE_DEV_SERVER_URL; + const devServerUrl = app.isPackaged ? null : process.env.VITE_DEV_SERVER_URL; if (devServerUrl) { mainWindow.loadURL(devServerUrl); return; @@ -1920,6 +1474,12 @@ function createMainWindow() { } function registerIpcHandlers() { + const trustedIpc = createTrustedIpc({ ipcMain, getWindow: () => mainWindow, getPolicy: rendererPolicy, getSteamStatus, onRejected: (channel) => logSteamSyncEvent('IPC_REJECTED', { channel }) }); + for (const channel of ['credentials:status', 'credentials:get', 'credentials:set', 'credentials:remove']) ipcMain.removeHandler(channel); + trustedIpc.handle('credentials:status', () => getCredentialVault().status()); + trustedIpc.handle('credentials:get', (_event, payload) => getCredentialVault().get(payload.profileId, payload.key)); + trustedIpc.handle('credentials:set', (_event, payload) => getCredentialVault().set(payload.profileId, payload.key, payload.value)); + trustedIpc.handle('credentials:remove', (_event, payload) => getCredentialVault().remove(payload.profileId, payload.key)); ipcMain.removeAllListeners('menu:set-context'); ipcMain.removeAllListeners('sync:trace'); ipcMain.removeHandler('shell:open-external'); @@ -1927,6 +1487,7 @@ function registerIpcHandlers() { ipcMain.removeHandler('shell:open-containing-folder'); ipcMain.removeHandler('dialog:save-file'); ipcMain.removeHandler('auth:start-google-oauth'); + ipcMain.removeHandler('auth:cancel-google-oauth'); ipcMain.removeHandler('steam:get-status'); ipcMain.removeHandler('steam:open-overlay'); ipcMain.removeHandler('steam:set-rich-presence'); @@ -1938,7 +1499,7 @@ function registerIpcHandlers() { ipcMain.removeHandler('steam-sync:update-file'); ipcMain.removeHandler('steam-sync:delete-file'); - ipcMain.handle('shell:open-external', async (_event, rawUrl) => { + trustedIpc.handle('shell:open-external', async (_event, rawUrl) => { if (!isAllowedExternalUrl(rawUrl)) { return false; } @@ -1946,87 +1507,72 @@ function registerIpcHandlers() { return true; }); - ipcMain.handle('shell:open-path', async (_event, rawPath) => { + trustedIpc.handle('shell:open-path', async (_event, rawPath) => { return await openPathInShell(rawPath); }); - ipcMain.handle('shell:open-containing-folder', async (_event, rawFilePath) => { + trustedIpc.handle('shell:open-containing-folder', async (_event, rawFilePath) => { return await openContainingFolderInShell(rawFilePath); }); - ipcMain.handle('dialog:save-file', async (_event, payload) => { + trustedIpc.handle('dialog:save-file', async (_event, payload) => { return await saveFileFromRenderer(payload || {}); }); - ipcMain.handle('auth:start-google-oauth', async (_event, payload) => { - return startGoogleOAuth(payload || {}); + trustedIpc.handle('auth:cancel-google-oauth', () => { oauthSession.cancel(); return true; }); + trustedIpc.handle('auth:start-google-oauth', async (_event, payload) => { + return oauthSession.run((signal) => startGoogleOAuth(payload, signal)); }); - ipcMain.handle('steam:get-status', async () => { + trustedIpc.handle('steam:get-status', async () => { return getSteamStatus(); }); - ipcMain.handle('steam:open-overlay', async (_event, payload) => { + trustedIpc.handle('steam:open-overlay', async (_event, payload) => { const dialog = payload?.dialog || 'Friends'; const ok = openSteamOverlay(dialog); return { ok }; }); - ipcMain.handle('steam:set-rich-presence', async (_event, payload) => { + trustedIpc.handle('steam:set-rich-presence', async (_event, payload) => { const entries = payload?.entries || {}; return setSteamRichPresence(entries); }); - ipcMain.handle('steam:unlock-achievement', async (_event, payload) => { + trustedIpc.handle('steam:unlock-achievement', async (_event, payload) => { const achievementId = payload?.achievementId || ''; return unlockSteamAchievement(achievementId); }); - ipcMain.handle('steam-sync:find-file', async (_event, payload) => { + trustedIpc.handle('steam-sync:find-file', async (_event, payload) => { return await steamSyncFindFile(payload || {}); }); - ipcMain.handle('steam-sync:list-files', async (_event, payload) => { + trustedIpc.handle('steam-sync:list-files', async (_event, payload) => { return await steamSyncListFiles(payload || {}); }); - ipcMain.handle('steam-sync:download-file', async (_event, payload) => { + trustedIpc.handle('steam-sync:download-file', async (_event, payload) => { return await steamSyncDownloadFile(payload || {}); }); - ipcMain.handle('steam-sync:create-file', async (_event, payload) => { + trustedIpc.handle('steam-sync:create-file', async (_event, payload) => { return await steamSyncCreateFile(payload || {}); }); - ipcMain.handle('steam-sync:update-file', async (_event, payload) => { + trustedIpc.handle('steam-sync:update-file', async (_event, payload) => { return await steamSyncUpdateFile(payload || {}); }); - ipcMain.handle('steam-sync:delete-file', async (_event, payload) => { + trustedIpc.handle('steam-sync:delete-file', async (_event, payload) => { return await steamSyncDeleteFile(payload || {}); }); - ipcMain.on('menu:set-context', (_event, payload) => { + trustedIpc.on('menu:set-context', (_event, payload) => { updateMenuContext(payload || {}); }); - ipcMain.on('sync:trace', (_event, payload) => { - const eventName = String(payload?.event || 'event'); - const details = payload?.details && typeof payload.details === 'object' - ? payload.details - : {}; - const timestamp = new Date().toISOString(); - const line = `[${timestamp}] [sync] ${eventName} ${JSON.stringify(details)}`; - console.log(line); - try { - if (app.isReady()) { - const diagnosticsPath = path.join(app.getPath('userData'), 'steam-sync-diagnostics.log'); - fs.appendFileSync(diagnosticsPath, `${line}\n`, 'utf8'); - } - } catch (error) { - // Best-effort diagnostics logging only. - } - }); + trustedIpc.on('sync:trace', (_event, payload) => { logSteamSyncEvent(payload.event || 'event', payload.details || {}); }); } const gotSingleInstanceLock = app.requestSingleInstanceLock(); @@ -2051,6 +1597,7 @@ if (!gotSingleInstanceLock) { }); app.whenReady().then(async () => { + if (process.env.MOUNTEA_STARTUP_CHECK === '1') require('electron').session.defaultSession.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*'] }, (_details, callback) => callback({ cancel: true })); steamRuntimeState = initializeSteamRuntime(); if (steamRuntimeState.available) { console.log( @@ -2091,6 +1638,7 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { + oauthSession.cancel(); try { shutdownSteamRuntime(); } catch (error) { diff --git a/electron/oauth-session.cjs b/electron/oauth-session.cjs new file mode 100644 index 0000000..42362b7 --- /dev/null +++ b/electron/oauth-session.cjs @@ -0,0 +1,18 @@ +// Keep the session locked until cleanup completes, including token exchange. +function createOAuthSession() { + let active = null; + return { + async run(operation) { + if (active) throw new Error('Authentication already in progress'); + const controller = new AbortController(); + active = controller; + try { + const result = await operation(controller.signal); + controller.signal.throwIfAborted(); + return result; + } finally { if (active === controller) active = null; } + }, + cancel() { active?.abort(new Error('Authentication cancelled')); }, + }; +} +module.exports = { createOAuthSession }; diff --git a/electron/preload.cjs b/electron/preload.cjs index e975352..c153bbe 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -2,7 +2,12 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { isElectron: true, + credentialStatus: () => ipcRenderer.invoke('credentials:status'), + getCredential: (payload) => ipcRenderer.invoke('credentials:get', payload), + setCredential: (payload) => ipcRenderer.invoke('credentials:set', payload), + removeCredential: (payload) => ipcRenderer.invoke('credentials:remove', payload), startGoogleOAuth: (payload) => ipcRenderer.invoke('auth:start-google-oauth', payload), + cancelGoogleOAuth: () => ipcRenderer.invoke('auth:cancel-google-oauth', {}), openExternal: (url) => ipcRenderer.invoke('shell:open-external', url), openPath: (targetPath) => ipcRenderer.invoke('shell:open-path', targetPath), openContainingFolder: (filePath) => diff --git a/electron/security.cjs b/electron/security.cjs new file mode 100644 index 0000000..c9e6b4b --- /dev/null +++ b/electron/security.cjs @@ -0,0 +1,110 @@ +const { pathToFileURL } = require('node:url'); + +function isTrustedRendererUrl(rawUrl, { distIndexPath, devServerUrl, isPackaged = false }) { + try { + const url = new URL(rawUrl); + if (!isPackaged && devServerUrl) { + const dev = new URL(devServerUrl); + return ['http:', 'https:'].includes(dev.protocol) && url.origin === dev.origin && url.pathname === dev.pathname && url.search === dev.search && !url.username && !url.password; + } + const expected = pathToFileURL(distIndexPath); + return url.protocol === 'file:' && url.host === expected.host && url.pathname === expected.pathname && !url.search; + } catch { return false; } +} + +function requireObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid IPC object'); +} +function requireString(value, max = 4096, optional = false) { + if ((optional && value === undefined) || (typeof value === 'string' && value.length <= max && (optional || value.length > 0))) return; + throw new Error('Invalid IPC string'); +} +function validatePayload(channel, payload, status) { + if (['steam:get-status', 'credentials:status'].includes(channel)) { if (payload !== undefined) throw new Error('Unexpected IPC payload'); return; } + if (channel.startsWith('shell:')) { requireString(payload); return; } + requireObject(payload); + if (channel.startsWith('credentials:')) { + const expected = status?.available && status?.steamId ? `steam-${status.steamId}` : 'local'; + if (payload.profileId !== expected) throw new Error('Credential profile mismatch'); + if (!['googleDrive-account', 'googleDrive-passphrase'].includes(payload.key)) throw new Error('Unknown credential capability'); + if (channel === 'credentials:set') requireString(payload.value, 65536); + return; + } + if (channel.startsWith('steam-sync:')) { + if (!status?.available || !status.steamId || payload.profileId !== `steam-${status.steamId}`) throw new Error('Steam profile unavailable or mismatched'); + if (channel.endsWith('find-file')) requireString(payload.fileName, 255); + if (channel.endsWith('list-files')) requireString(payload.namePrefix, 255, true); + if (/download-file|update-file|delete-file/.test(channel)) requireString(payload.fileId, 255); + if (/create-file|update-file/.test(channel)) { + requireString(payload.name, 255, channel.endsWith('update-file')); + requireString(payload.content, 32 * 1024 * 1024); + requireString(payload.mimeType, 128, true); + if (payload.appProperties !== undefined) { + requireObject(payload.appProperties); + if (JSON.stringify(payload.appProperties).length > 8192) throw new Error('IPC metadata exceeds limit'); + } + } + return; + } + if (channel === 'auth:start-google-oauth') { + requireString(payload.clientId, 512); + if (Object.keys(payload).some((key) => !['clientId', 'scopes'].includes(key))) throw new Error('Unknown OAuth argument'); + if (!Array.isArray(payload.scopes) || payload.scopes.length > 10 || payload.scopes.some((scope) => typeof scope !== 'string' || !['openid', 'email', 'profile', 'https://www.googleapis.com/auth/drive.appdata', 'https://www.googleapis.com/auth/drive'].includes(scope))) throw new Error('Invalid OAuth scopes'); + return; + } + if (channel === 'auth:cancel-google-oauth') { if (Object.keys(payload).length) throw new Error('Unexpected OAuth argument'); return; } + if (channel === 'dialog:save-file') { + requireString(payload.fileBase64, 48 * 1024 * 1024); + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(payload.fileBase64)) throw new Error('Invalid file bytes'); + requireString(payload.defaultPath, 4096, true); + requireString(payload.title, 256, true); + if (payload.filters !== undefined) { + if (!Array.isArray(payload.filters) || payload.filters.length > 20) throw new Error('Invalid file filters'); + for (const filter of payload.filters) { + requireObject(filter); requireString(filter.name, 128); + if (!Array.isArray(filter.extensions) || filter.extensions.length > 20 || filter.extensions.some((extension) => typeof extension !== 'string' || !/^[A-Za-z0-9*]{1,32}$/.test(extension))) throw new Error('Invalid file extension'); + } + } + return; + } + if (channel === 'steam:open-overlay') { + if (!status?.available || !['Friends', 'Community', 'Players', 'Settings', 'OfficialGameGroup', 'Stats', 'Achievements'].includes(payload.dialog || 'Friends')) throw new Error('Invalid Steam overlay capability'); + return; + } + if (channel === 'steam:unlock-achievement') { + if (!status?.available || !['EXAMPLE_PROJECT', 'FIRST_PROJECT', 'POWER_USER_10H', 'FIRST_CONDITION', 'FIRST_DECORATOR', 'FIRST_PARTICIPANT', 'FIRST_CATEGORY'].includes(payload.achievementId)) throw new Error('Invalid achievement capability'); + return; + } + if (channel === 'steam:set-rich-presence') { + if (!status?.available) throw new Error('Steam unavailable'); + requireObject(payload.entries); + if (Object.keys(payload.entries).length > 20) throw new Error('Too many presence entries'); + for (const [key, value] of Object.entries(payload.entries)) { requireString(key, 64); if (value !== null) requireString(value, 256, true); } + return; + } + if (['menu:set-context', 'sync:trace'].includes(channel)) { + if (JSON.stringify(payload).length > 8192) throw new Error('IPC payload exceeds limit'); + if (channel === 'menu:set-context') { + for (const key of ['route', 'projectId', 'dialogueId', 'appLanguage', 'contentLocale']) requireString(payload[key], 256, true); + if (payload.supportedContentLocales !== undefined && (!Array.isArray(payload.supportedContentLocales) || payload.supportedContentLocales.length > 100 || payload.supportedContentLocales.some((locale) => typeof locale !== 'string' || locale.length > 64))) throw new Error('Invalid menu locales'); + } else { + requireString(payload.event, 128); + if (payload.details !== undefined) requireObject(payload.details); + } + return; + } + throw new Error('Unknown IPC capability'); +} + +function createTrustedIpc({ ipcMain, getWindow, getPolicy, getSteamStatus, onRejected = () => {} }) { + const authorize = (event, channel, payload) => { + const contents = getWindow()?.webContents; + if (!contents || event.sender !== contents || event.senderFrame !== contents.mainFrame || !isTrustedRendererUrl(event.senderFrame.url, getPolicy())) throw new Error('Untrusted IPC sender'); + validatePayload(channel, payload, getSteamStatus()); + }; + return { + handle(channel, handler) { ipcMain.handle(channel, (event, payload) => { authorize(event, channel, payload); return handler(event, payload); }); }, + on(channel, handler) { ipcMain.on(channel, (event, payload) => { try { authorize(event, channel, payload); handler(event, payload); } catch (error) { onRejected(channel, error); } }); }, + }; +} +module.exports = { isTrustedRendererUrl, validatePayload, createTrustedIpc }; diff --git a/electron/sentry.cjs b/electron/sentry.cjs index 7261c5b..50d2f3c 100644 --- a/electron/sentry.cjs +++ b/electron/sentry.cjs @@ -7,10 +7,22 @@ try { } let sentryInitialized = false; +const { app } = require('electron'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); + +function resolveRelease() { + try { + const manifest = JSON.parse(readFileSync(path.join(app.getAppPath(), 'dist', 'release.json'), 'utf8')); + if (typeof manifest.release === 'string' && manifest.release.trim()) return manifest.release; + } catch { /* Development may launch before a renderer build. */ } + return `mountea-dialoguer@${app.getVersion()}+local.unbuilt`; +} function toNumberOrFallback(value, fallback) { + if (value == null || String(value).trim() === '') return fallback; const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; + return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : fallback; } function resolveMainProcessDsn() { @@ -25,6 +37,7 @@ function initMainProcessSentry() { if (!Sentry) return false; const dsn = resolveMainProcessDsn(); if (!dsn) return false; + if (!app.isPackaged && process.env.MOUNTEA_SENTRY_ENABLE_IN_DEV !== '1') return false; Sentry.init({ dsn, @@ -34,7 +47,7 @@ function initMainProcessSentry() { process.env.VITE_SENTRY_TRACES_SAMPLE_RATE, 0.1 ), - release: String(process.env.npm_package_version || ''), + release: resolveRelease(), }); sentryInitialized = true; diff --git a/electron/steam-files.cjs b/electron/steam-files.cjs new file mode 100644 index 0000000..e9a6c1d --- /dev/null +++ b/electron/steam-files.cjs @@ -0,0 +1,79 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +// Immutable account-scoped objects. Local cache is evidence, never an upload +// acknowledgement or an authority for remote absence. +function createSteamFileTransport({ cloud, directory, readLegacy = async () => [] }) { + let pending = Promise.resolve(); + const serial = (action) => { const result = pending.then(action, action); pending = result.catch(() => {}); return result; }; + const segment = (value) => { if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('Invalid Steam identity'); return value; }; + const prefix = (profileId) => `mountea-v2__${segment(profileId)}__`; + const filename = (profileId, id) => `${prefix(profileId)}${segment(id)}.json`; + const ready = () => { const status = cloud.status(); if (!status?.available || !status.enabledForApp || !status.enabledForAccount) throw new Error('Steam Cloud unavailable'); }; + const metadata = (entry) => { const result = { ...entry }; delete result.content; return result; }; + const readEntry = (raw, profileId) => { + const entry = JSON.parse(raw); + if (entry.version !== 2 || entry.profileId !== profileId || typeof entry.content !== 'string' || typeof entry.name !== 'string' || entry.hash !== crypto.createHash('sha256').update(entry.content).digest('hex')) throw new Error('Corrupt Steam Cloud object'); + segment(entry.id); + return entry; + }; + const cache = async (profileId, entry) => { + const folder = path.join(directory, segment(profileId), 'v2'); + await fs.mkdir(folder, { recursive: true }); + const target = path.join(folder, `${segment(entry.id)}.json`), temporary = `${target}.${crypto.randomUUID()}.tmp`; + try { await fs.writeFile(temporary, JSON.stringify(entry)); await fs.rename(temporary, target); } + finally { await fs.rm(temporary, { force: true }); } + }; + const entries = async (profileId) => { + ready(); + const names = [...new Set(await cloud.list())].filter((name) => name.startsWith(prefix(profileId)) && name.endsWith('.json')); + const result = []; + for (const name of names) result.push(readEntry(await cloud.read(name), profileId)); + return result; + }; + const list = ({ profileId, namePrefix = '' }) => serial(async () => { + const current = await entries(profileId); + const legacy = namePrefix.startsWith('mountea-v2') ? [] : await readLegacy(profileId); + return [...current, ...legacy].filter((entry) => entry.name.startsWith(namePrefix)).map(metadata); + }); + const download = ({ profileId, fileId }) => serial(async () => { + ready(); + const names = await cloud.list(); + if (names.includes(filename(profileId, fileId))) return readEntry(await cloud.read(filename(profileId, fileId)), profileId).content; + const legacy = (await readLegacy(profileId)).find((entry) => entry.id === fileId); + if (!legacy) throw new Error('Steam Cloud object missing'); + return legacy.content; + }); + const create = (payload) => serial(async () => { + const { profileId, name, content, mimeType = 'application/json', appProperties = {} } = payload; + ready(); + // Deterministic identity makes retries idempotent even after a lost reply. + const hash = crypto.createHash('sha256').update(content).digest('hex'); + const id = crypto.createHash('sha256').update(`${name}\0${hash}`).digest('hex'); + const entry = { version: 2, profileId, id, name, content, hash, mimeType, appProperties, modifiedTime: new Date().toISOString() }; + await cache(profileId, { ...entry, uploadState: 'pending' }); + const remoteName = filename(profileId, id); + const names = await cloud.list(); + if (!names.includes(remoteName) && !(await cloud.write(remoteName, JSON.stringify(entry)))) throw new Error('Steam Cloud upload failed; local revision remains pending (quota or provider unavailable)'); + const verified = readEntry(await cloud.read(remoteName), profileId); + if (verified.hash !== hash || verified.name !== name) throw new Error('Steam Cloud verification failed'); + await cache(profileId, { ...verified, uploadState: 'verified' }); + return { ...metadata(verified), cloudWritten: true, uploadState: 'verified' }; + }); + return { + list, + find: async ({ profileId, fileName }) => (await list({ profileId, namePrefix: fileName })).find((entry) => entry.name === fileName) || null, + download, + create, + // An update publishes a new object; the old object remains readable. + update: async (payload) => { + const existing = (await list({ profileId: payload.profileId })).find((entry) => entry.id === payload.fileId); + if (!existing) throw new Error('Steam Cloud object missing'); + return create({ ...existing, ...payload, name: payload.name || existing.name }); + }, + remove: () => Promise.reject(new Error('Remote history deletion is disabled; publish a deletion revision')), + }; +} +module.exports = { createSteamFileTransport }; + diff --git a/electron/steam.cjs b/electron/steam.cjs index 6a46766..b51f6d8 100644 --- a/electron/steam.cjs +++ b/electron/steam.cjs @@ -329,21 +329,11 @@ function getSteamCloudStatus() { function listSteamCloudFileNames() { const cloud = getSteamCloudApi(); - if (!cloud || typeof cloud?.listFiles !== 'function') { - return []; - } - - try { - const files = cloud.listFiles(); - if (!Array.isArray(files)) return []; - return files - .map((item) => String(item?.name || '').trim()) - .filter(Boolean); - } catch (error) { - return []; - } + if (!cloud || typeof cloud.listFiles !== 'function') throw new Error('Steam Cloud listing unavailable'); + const files = cloud.listFiles(); + if (!Array.isArray(files)) throw new Error('Invalid Steam Cloud listing'); + return files.map((item) => String(item?.name || '').trim()).filter(Boolean); } - function steamCloudFileExists(name) { const fileName = String(name || '').trim(); if (!fileName) return false; From 1aa9ac13b18d31cfc0d5c6a313fc91f97ae175cf Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:12 +0200 Subject: [PATCH 05/22] A: Add release and native validation scripts Add product build, release artifact, signing, startup and native package validation scripts; update dev and builder scripts. --- scripts/check-electron-startup.mjs | 161 +++++++++++++++++++++++++++ scripts/dev-electron.mjs | 3 - scripts/native-test-support.mjs | 10 ++ scripts/product-build.mjs | 31 ++++++ scripts/release-artifact.mjs | 70 ++++++++++++ scripts/run-electron-builder.mjs | 94 +++++----------- scripts/sign-linux-release.mjs | 27 +++++ scripts/update-technical-indexes.mjs | 46 ++++++++ scripts/validate-native-package.mjs | 15 +++ 9 files changed, 390 insertions(+), 67 deletions(-) create mode 100644 scripts/check-electron-startup.mjs create mode 100644 scripts/native-test-support.mjs create mode 100644 scripts/product-build.mjs create mode 100644 scripts/release-artifact.mjs create mode 100644 scripts/sign-linux-release.mjs create mode 100644 scripts/update-technical-indexes.mjs create mode 100644 scripts/validate-native-package.mjs diff --git a/scripts/check-electron-startup.mjs b/scripts/check-electron-startup.mjs new file mode 100644 index 0000000..793c547 --- /dev/null +++ b/scripts/check-electron-startup.mjs @@ -0,0 +1,161 @@ +import { _electron as electron, expect } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { seedLocalState, createProject, openDialoguesSection, createDialogue } from '../tests/e2e/helpers/appHarness.js'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { verifyArtifact } from './release-artifact.mjs'; +import integrity from '../electron/artifact-integrity.cjs'; +import { assertSameProfileDirectory, NATIVE_TEST_VIEWPORT } from './native-test-support.mjs'; +const executablePath = process.env.MOUNTEA_PACKAGED_EXECUTABLE; +const evidence = { schemaVersion: 1, status: 'failed', platform: process.platform, architecture: process.arch, packaged: Boolean(executablePath), checks: {}, externalGates: ['production-signing', 'notarization', 'live-providers'] }; +const pageErrors = []; + +// This checks real Electron and optional installed-package contents; signing is separate. +const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mountea-electron-check-')); +const resolved = path.resolve(directory); +if (path.dirname(resolved) !== path.resolve(os.tmpdir()) || !path.basename(resolved).startsWith('mountea-electron-check-')) throw new Error('Refusing unexpected cleanup path'); +const bootstrap = path.join(directory, 'bootstrap.cjs'); +const main = path.resolve('electron/main.cjs'); +await fs.writeFile(bootstrap, ` +const { app, BrowserWindow, session } = require('electron'); +app.setPath('userData', ${JSON.stringify(path.join(directory, 'user-data'))}); +BrowserWindow.prototype.show = function () {}; +app.whenReady().then(() => session.defaultSession.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*'] }, (_details, callback) => callback({ cancel: true }))); +require(${JSON.stringify(main)}); +`); +let application; +const launchEnvironment = { ...process.env, MOUNTEA_STARTUP_CHECK: '1', VITE_DEV_SERVER_URL: '', VITE_DIST_CHANNEL: 'desktop', MOUNTEA_DIST_CHANNEL: 'desktop', MOUNTEA_SENTRY_DSN: '', VITE_SENTRY_DSN: '', MOUNTEA_USER_DATA_DIR: path.join(directory, 'user-data') }; +delete launchEnvironment.ELECTRON_RUN_AS_NODE; +try { + if (executablePath) { + const inventory = await verifyArtifact('dist'); + evidence.release = inventory.release; + evidence.inventorySha256 = createHash('sha256').update(await fs.readFile('dist/artifact-integrity.json')).digest('hex'); + } + const launch = async () => { + const app = await electron.launch({ + ...(executablePath ? { executablePath: path.resolve(executablePath) } : {}), + args: [...(executablePath ? [] : [bootstrap]), `--user-data-dir=${path.join(directory, 'user-data')}`], + env: launchEnvironment, + timeout: 30000, + }); + app.context().on('page', (entry) => entry.on('pageerror', (error) => pageErrors.push(error.message))); + for (const entry of app.context().pages()) entry.on('pageerror', (error) => pageErrors.push(error.message)); + await app.firstWindow(); + await app.evaluate(({ BrowserWindow }, viewport) => { + globalThis.nativeValidationFailures = []; + for (const window of BrowserWindow.getAllWindows()) { + window.setContentSize(viewport.width, viewport.height); + window.webContents.on('render-process-gone', (_event, details) => globalThis.nativeValidationFailures.push(`renderer: ${details.reason}`)); + window.webContents.on('did-fail-load', (_event, code) => globalThis.nativeValidationFailures.push(`load: ${code}`)); + } + }, NATIVE_TEST_VIEWPORT); + return app; + }; + application = await launch(); + let page = await application.firstWindow(); + await page.waitForFunction(() => Boolean(window.electronAPI?.isElectron) && document.querySelector('#root')?.textContent.length > 50); + await page.waitForTimeout(2000); + const result = await application.evaluate(({ app, BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return { platform: process.platform, architecture: process.arch, version: app.getVersion(), userData: app.getPath('userData'), visible: window.isVisible(), preferences: window.webContents.getLastWebPreferences(), url: window.webContents.getURL() }; + }); + assert.equal(result.platform, process.env.MOUNTEA_EXPECTED_PLATFORM || process.platform); + assert.equal(result.architecture, process.env.MOUNTEA_EXPECTED_ARCH || process.arch); + if (executablePath) assert.equal(result.version, JSON.parse(await fs.readFile('package.json', 'utf8')).version); + evidence.runtime = { platform: result.platform, architecture: result.architecture, version: result.version }; + await assertSameProfileDirectory(result.userData, path.join(directory, 'user-data')); + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(NATIVE_TEST_VIEWPORT.width); + evidence.viewport = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })); + assert.equal(result.visible, false); + assert.equal(result.preferences.sandbox, true); + assert.equal(result.preferences.contextIsolation, true); + assert.equal(result.preferences.nodeIntegration, false); + assert.equal(new URL(result.url).protocol, 'file:'); + const status = await page.evaluate(() => window.electronAPI.getSteamStatus()); + assert.equal(status.available, false); + const credentials = await page.evaluate(() => window.electronAPI.credentialStatus()); + assert.equal(typeof credentials.canRemember, 'boolean'); + const capability = await application.evaluate(({ safeStorage }) => ({ encryptionAvailable: safeStorage.isEncryptionAvailable(), backend: typeof safeStorage.getSelectedStorageBackend === 'function' ? safeStorage.getSelectedStorageBackend() : 'os-protected' })); + assert.equal(credentials.canRemember, capability.encryptionAvailable && (result.platform !== 'linux' || capability.backend !== 'basic_text')); + evidence.credentials = { canRemember: credentials.canRemember, ...capability }; + const originalUrl = page.url(); + await page.evaluate(() => { location.href = 'file:///untrusted-native-check.html'; }); + await page.waitForTimeout(250); + assert.equal(page.url(), originalUrl); + assert.ok(!(await page.locator('body').innerText()).includes('Local storage could not be opened safely')); + if (executablePath) { + const expected = integrity.fingerprintDirectory('dist'); + const actual = await application.evaluate(({ app }) => { + // Playwright evaluates outside the application's CommonJS module scope. + const path = process.getBuiltinModule('path'); + const load = process.getBuiltinModule('module').createRequire(path.join(app.getAppPath(), 'package.json')); + return load(path.join(app.getAppPath(), 'electron', 'artifact-integrity.cjs')).fingerprintDirectory(path.join(app.getAppPath(), 'dist')); + }); + assert.deepEqual(actual, expected, 'Every packaged renderer file must match the validated artifact'); + evidence.checks.packagedRendererIntegrity = 'passed'; + } + evidence.checks.startup = 'passed'; + await seedLocalState(page); + await page.reload(); + await createProject(page, 'NativeValidationProject'); + await openDialoguesSection(page); + await createDialogue(page, 'NativeValidationDialogue'); + await page.locator('[data-tour="node-toolbar"]').getByRole('button', { name: 'Delay', exact: true }).click(); + await expect(page.locator('.react-flow__node-delayNode')).toHaveCount(1); + await page.keyboard.press(result.platform === 'darwin' ? 'Meta+s' : 'Control+s'); + const dialogueUrl = page.url(); + const dialogueId = new URL(dialogueUrl).hash.split('/').filter(Boolean).at(-1); + // Read authored records through IndexedDB without exposing a production test bridge. + const readAuthored = async () => page.evaluate(async (id) => { + const databases = await indexedDB.databases(); + for (const { name } of databases) { + const database = await new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); + try { + if (!database.objectStoreNames.contains('projectState')) continue; + const tables = ['projects', 'dialogues', 'nodes', 'edges', 'localizedStrings', 'projectState', 'projectRevisions']; + const transaction = database.transaction(tables, 'readonly'); + const entries = await Promise.all(tables.map((table) => new Promise((resolve, reject) => { const request = transaction.objectStore(table).getAll(); request.onsuccess = () => resolve([table, request.result]); request.onerror = () => reject(request.error); }))); + const records = Object.fromEntries(entries); + if (records.dialogues.some((row) => row.id === id)) return records; + } finally { database.close(); } + } + return null; + }, dialogueId); + await expect.poll(async () => (await readAuthored())?.nodes.filter((node) => node.dialogueId === dialogueId && node.type === 'delayNode').length).toBe(1); + const before = await readAuthored(); + assert.ok(before.projectRevisions.length > 0, 'Authoring must persist revision history'); + assert.deepEqual(await application.evaluate(() => globalThis.nativeValidationFailures), []); + await application.close(); + application = await launch(); + page = await application.firstWindow(); + await page.waitForFunction(() => Boolean(window.electronAPI?.isElectron) && document.querySelector('#root')?.textContent.length > 50); + await page.goto(dialogueUrl); + await expect(page.locator('.react-flow__node-delayNode')).toHaveCount(1); + assert.deepEqual(await readAuthored(), before, 'Persisted authored records and revisions must survive a full native process restart unchanged'); + evidence.checks.offlineCreateSaveRestart = 'passed'; + evidence.persistence = { authoredRecordsSha256: createHash('sha256').update(JSON.stringify(before)).digest('hex'), dialogueCount: before.dialogues.length, nodeCount: before.nodes.length, revisionCount: before.projectRevisions.length }; + assert.ok(!(await page.locator('body').innerText()).includes('Local storage could not be opened safely')); + assert.deepEqual(pageErrors, [], 'No uncaught renderer errors'); + assert.deepEqual(await application.evaluate(() => globalThis.nativeValidationFailures), [], 'No native renderer initialization failures'); + evidence.checks.rendererErrors = 'passed'; + evidence.status = 'passed'; + Object.assign(evidence, { sandbox: true, preload: true, unexpectedNavigation: 'denied', hidden: true, disposableUserData: true, providerNetwork: 'blocked' }); +} catch (error) { + evidence.failure = { name: error.name, message: error.message }; + throw error; +} finally { + try { await application?.close(); } + finally { + await fs.rm(resolved, { recursive: true, force: true }); + if (process.env.MOUNTEA_NATIVE_EVIDENCE_PATH) { + const output = path.resolve(process.env.MOUNTEA_NATIVE_EVIDENCE_PATH); + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.writeFile(output, JSON.stringify(evidence, null, 2) + '\n'); + } + console.log(JSON.stringify(evidence)); + } +} + diff --git a/scripts/dev-electron.mjs b/scripts/dev-electron.mjs index 477d4ba..a7b21b0 100644 --- a/scripts/dev-electron.mjs +++ b/scripts/dev-electron.mjs @@ -13,11 +13,8 @@ const DEFAULT_CHOKIDAR_INTERVAL_MS = 300; const LINUX_ELECTRON_SAFE_ARGS = Object.freeze([ "--disable-gpu", "--disable-software-rasterizer", - "--disable-gpu-sandbox", "--in-process-gpu", "--use-gl=swiftshader", - "--no-sandbox", - "--disable-setuid-sandbox", "--ozone-platform-hint=x11", ]); diff --git a/scripts/native-test-support.mjs b/scripts/native-test-support.mjs new file mode 100644 index 0000000..7e37c3e --- /dev/null +++ b/scripts/native-test-support.mjs @@ -0,0 +1,10 @@ +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; + +export const NATIVE_TEST_VIEWPORT = { width: 1280, height: 900 }; + +// macOS exposes /var through /private/var; lexical equality rejects the same +// isolated directory. Resolve both, while still rejecting any other profile. +export async function assertSameProfileDirectory(actual, expected) { + assert.equal(await fs.realpath(actual), await fs.realpath(expected), 'Native validation must use the disposable profile directory'); +} diff --git a/scripts/product-build.mjs b/scripts/product-build.mjs new file mode 100644 index 0000000..658ca37 --- /dev/null +++ b/scripts/product-build.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; + +export function resolveReleaseIdentity({ version, env = process.env, git = (args) => execFileSync('git', args, { encoding: 'utf8', windowsHide: true }).trim() }) { + const explicit = String(env.MOUNTEA_RELEASE || '').trim(); + if (explicit) return explicit; + if (env.CI && env.GITHUB_SHA) return `mountea-dialoguer@${version}+${env.GITHUB_SHA}`; + let revision = 'unknown'; + let dirty = true; + try { revision = git(['rev-parse', 'HEAD']); dirty = Boolean(git(['status', '--porcelain'])); } catch { /* Source archives have no Git metadata. */ } + return `mountea-dialoguer@${version}+local.${revision}${dirty ? '.dirty' : ''}`; +} + +export function productBuildPlugin(release, reportingEnabled = false) { + const filename = 'onboarding-example.mnteadlgproj'; + const source = readFileSync(new URL('../ExampleProject/OnboardingExample.mnteadlgproj', import.meta.url)); + return { + name: 'mountea-product-artifacts', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url?.split('?')[0] !== `/${filename}`) return next(); + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(source); + }); + }, + generateBundle() { + this.emitFile({ type: 'asset', fileName: filename, source }); + this.emitFile({ type: 'asset', fileName: 'release.json', source: JSON.stringify({ release, reportingEnabled }, null, 2) + '\n' }); + }, + }; +} diff --git a/scripts/release-artifact.mjs b/scripts/release-artifact.mjs new file mode 100644 index 0000000..9d2fe5a --- /dev/null +++ b/scripts/release-artifact.mjs @@ -0,0 +1,70 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const inventoryName = 'artifact-integrity.json'; +const validationName = 'artifact-validation.json'; +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); +export async function filesUnder(directory, prefix = '') { + const result = []; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const name = prefix + entry.name; + if (entry.isSymbolicLink()) throw new Error(`Release artifacts cannot contain symbolic links: ${name}`); + if (entry.isDirectory()) result.push(...await filesUnder(path.join(directory, entry.name), `${name}/`)); + else if (entry.isFile()) result.push(name); + } + return result.sort(); +} +async function inventory(directory) { + const files = (await filesUnder(directory)).filter((name) => ![inventoryName, validationName].includes(name)); + if (files.some((name) => name.endsWith('.map'))) throw new Error('Private source maps must be removed before validation or distribution.'); + if (!files.includes('index.html') || !files.includes('release.json') || !files.includes('onboarding-example.mnteadlgproj')) throw new Error('The renderer artifact is incomplete.'); + return Object.fromEntries(await Promise.all(files.map(async (name) => [name, digest(await fs.readFile(path.join(directory, name)))]))); +} +export async function finalizeArtifact(directory, { env = process.env, execute = spawnSync } = {}) { + const manifest = JSON.parse(await fs.readFile(path.join(directory, 'release.json'), 'utf8')); + if (typeof manifest.release !== 'string' || !manifest.release) throw new Error('A release identity is required.'); + const maps = (await filesUnder(directory)).filter((name) => name.endsWith('.map')); + if (manifest.reportingEnabled) { + for (const key of ['SENTRY_AUTH_TOKEN', 'SENTRY_ORG', 'SENTRY_PROJECT']) if (!env[key]) throw new Error(`Reporting-enabled releases require ${key}; source maps have not been uploaded.`); + if (!maps.length) throw new Error('Reporting-enabled releases require private source maps.'); + const args = ['releases', 'files', manifest.release, 'upload-sourcemaps', directory, '--url-prefix', '~/']; + // npm's Windows .cmd shim cannot be spawned with shell:false. Resolve the + // installed native binary instead; never put release inputs through a shell. + const command = env.SENTRY_CLI && !/\.(cmd|bat)$/i.test(env.SENTRY_CLI) ? env.SENTRY_CLI : createRequire(import.meta.url)('@sentry/cli').getPath(); + const uploaded = execute(command, args, { env, stdio: 'inherit', windowsHide: true, shell: false }); + if (uploaded.error || uploaded.status !== 0) throw new Error('Sentry source-map upload failed; artifact is not ready for validation.'); + } + for (const name of maps) await fs.unlink(path.join(directory, name)); + await fs.rm(path.join(directory, validationName), { force: true }); + const record = { version: 1, release: manifest.release, files: await inventory(directory) }; + await fs.writeFile(path.join(directory, inventoryName), JSON.stringify(record, null, 2) + '\n'); + return record; +} +export async function verifyArtifact(directory, { requireValidated = true } = {}) { + const contents = await fs.readFile(path.join(directory, inventoryName)); + const recorded = JSON.parse(contents), actual = await inventory(directory); + if (JSON.stringify(recorded.files) !== JSON.stringify(actual)) throw new Error('Renderer artifact changed after fingerprinting. Revalidate the rebuilt artifact.'); + const manifest = JSON.parse(await fs.readFile(path.join(directory, 'release.json'), 'utf8')); + if (recorded.release !== manifest.release) throw new Error('Artifact release identity mismatch.'); + if (requireValidated) { + const proof = JSON.parse(await fs.readFile(path.join(directory, validationName), 'utf8')); + if (proof.inventorySha256 !== digest(contents) || proof.release !== recorded.release || proof.productionBrowser !== 'passed') throw new Error('Artifact has no matching production validation record.'); + } + return recorded; +} +export async function attestArtifact(directory) { + const record = await verifyArtifact(directory, { requireValidated: false }); + await fs.writeFile(path.join(directory, validationName), JSON.stringify({ release: record.release, inventorySha256: digest(await fs.readFile(path.join(directory, inventoryName))), productionBrowser: 'passed' }, null, 2) + '\n'); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [command = 'verify', directory = 'dist'] = process.argv.slice(2); + if (command === 'finalize') await finalizeArtifact(directory); + else if (command === 'attest') await attestArtifact(directory); + else if (command === 'verify') await verifyArtifact(directory, { requireValidated: !process.argv.includes('--unvalidated') }); + else throw new Error(`Unknown artifact command: ${command}`); + console.log(`Artifact ${command} passed.`); +} diff --git a/scripts/run-electron-builder.mjs b/scripts/run-electron-builder.mjs index 27855c4..929e6a7 100644 --- a/scripts/run-electron-builder.mjs +++ b/scripts/run-electron-builder.mjs @@ -1,66 +1,32 @@ -import { spawn } from "node:child_process"; -import process from "node:process"; - -const FILTERED_PATTERNS = [ - "duplicate dependency references", - "skipped macOS notarization", -]; - -function commandForCurrentPlatform(command) { - return process.platform === "win32" ? `${command}.cmd` : command; -} - -function shouldFilterLine(line) { - return FILTERED_PATTERNS.some((pattern) => line.includes(pattern)); -} - -function forwardStream(stream, target, rawLogsEnabled) { - let pending = ""; - - stream.on("data", (chunk) => { - const text = pending + chunk.toString(); - const lines = text.split(/\r?\n/); - pending = lines.pop() ?? ""; - - for (const line of lines) { - if (rawLogsEnabled || !shouldFilterLine(line)) { - target.write(`${line}\n`); - } - } - }); - - stream.on("end", () => { - if (!pending) return; - if (rawLogsEnabled || !shouldFilterLine(pending)) { - target.write(`${pending}\n`); - } - }); +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { verifyArtifact } from './release-artifact.mjs'; +import fs from 'node:fs/promises'; +import { linuxSigningKeys, signLinuxArtifacts } from './sign-linux-release.mjs'; + +export function signingArguments(platform, env) { + const requireKeys = (keys) => { for (const key of keys) if (!env[key]) throw new Error(`Public ${platform} release requires ${key}; signing cannot be skipped.`); }; + if (platform === 'win32') { requireKeys(['CSC_LINK', 'CSC_KEY_PASSWORD']); return ['--config.forceCodeSigning=true']; } + if (platform === 'darwin') { requireKeys(['CSC_LINK', 'CSC_KEY_PASSWORD', 'APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID']); return ['--config.forceCodeSigning=true', '--config.mac.notarize=true']; } + if (platform === 'linux') { requireKeys(['MOUNTEA_LINUX_SIGNING_KEY_FILE', 'MOUNTEA_LINUX_PUBLIC_KEY_FILE']); return []; } + throw new Error(`Unsupported public release platform: ${platform}`); } - -function main() { - const args = process.argv.slice(2); - const rawLogsEnabled = process.env.ELECTRON_BUILDER_RAW_LOGS === "1"; - const isWindows = process.platform === "win32"; - const command = isWindows ? "cmd.exe" : commandForCurrentPlatform("npx"); - const commandArgs = isWindows - ? ["/d", "/s", "/c", "npx", "electron-builder", ...args] - : ["electron-builder", ...args]; - - const child = spawn(command, commandArgs, { - stdio: ["inherit", "pipe", "pipe"], - env: process.env, - }); - - forwardStream(child.stdout, process.stdout, rawLogsEnabled); - forwardStream(child.stderr, process.stderr, rawLogsEnabled); - - child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 1); - }); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2), publicRelease = args.includes('--public-release'); + await verifyArtifact('dist'); + const signing = publicRelease ? signingArguments(process.platform, process.env) : []; + let linuxOutput; + if (publicRelease && process.platform === 'linux') { + await linuxSigningKeys(); + await fs.mkdir('release', { recursive: true }); + linuxOutput = await fs.mkdtemp(path.resolve('release/linux-public-')); + signing.push(`--config.directories.output=${linuxOutput}`); + } + if (!publicRelease) console.log('Local package validation: this command does not establish a signed public release.'); + const child = spawn(process.execPath, [path.resolve('node_modules/electron-builder/out/cli/cli.js'), ...args.filter((arg) => arg !== '--public-release'), ...signing, '--publish', 'never'], { stdio: 'inherit', env: process.env, windowsHide: true }); + const code = await new Promise((resolve, reject) => { child.on('error', reject); child.on('exit', (value) => resolve(value ?? 1)); }); + await verifyArtifact('dist'); + if (code === 0 && linuxOutput) await signLinuxArtifacts(linuxOutput); + process.exitCode = code; } - -main(); diff --git a/scripts/sign-linux-release.mjs b/scripts/sign-linux-release.mjs new file mode 100644 index 0000000..f8ef2f6 --- /dev/null +++ b/scripts/sign-linux-release.mjs @@ -0,0 +1,27 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; +import { verifyArtifact } from './release-artifact.mjs'; + +export async function linuxSigningKeys(env = process.env) { + for (const key of ['MOUNTEA_LINUX_SIGNING_KEY_FILE', 'MOUNTEA_LINUX_PUBLIC_KEY_FILE']) if (!env[key]) throw new Error(`Public linux release requires ${key}; signing cannot be skipped.`); + const privateKey = createPrivateKey(await fs.readFile(env.MOUNTEA_LINUX_SIGNING_KEY_FILE)); + const publicKey = createPublicKey(await fs.readFile(env.MOUNTEA_LINUX_PUBLIC_KEY_FILE)); + if (privateKey.asymmetricKeyType !== 'ed25519' || publicKey.asymmetricKeyType !== 'ed25519') throw new Error('Linux release keys must use Ed25519.'); + if (!verify(null, Buffer.from('release-key-check'), publicKey, sign(null, Buffer.from('release-key-check'), privateKey))) throw new Error('Linux release signing keys do not match.'); + return { privateKey, publicKey }; +} +export async function signLinuxArtifacts(directory, { renderer = 'dist', env = process.env } = {}) { + const keys = await linuxSigningKeys(env), artifact = await verifyArtifact(renderer); + const names = (await fs.readdir(directory)).filter((name) => /\.(AppImage|deb)$/.test(name)).sort(); + if (!names.length) throw new Error('No Linux distribution artifacts were produced.'); + const files = {}; + for (const name of names) files[name] = createHash('sha256').update(await fs.readFile(path.join(directory, name))).digest('hex'); + const manifest = Buffer.from(JSON.stringify({ version: 1, release: artifact.release, rendererInventorySha256: createHash('sha256').update(await fs.readFile(path.join(renderer, 'artifact-integrity.json'))).digest('hex'), files }, null, 2) + '\n'); + const signature = sign(null, manifest, keys.privateKey); + if (!verify(null, manifest, keys.publicKey, signature)) throw new Error('Linux detached signature verification failed.'); + await fs.writeFile(path.join(directory, 'release-manifest.json'), manifest); + await fs.writeFile(path.join(directory, 'release-manifest.sig'), signature); + await fs.writeFile(path.join(directory, 'release-public-key.pem'), keys.publicKey.export({ type: 'spki', format: 'pem' })); + return JSON.parse(manifest); +} diff --git a/scripts/update-technical-indexes.mjs b/scripts/update-technical-indexes.mjs new file mode 100644 index 0000000..11b319c --- /dev/null +++ b/scripts/update-technical-indexes.mjs @@ -0,0 +1,46 @@ +import fs from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; + +const { parse } = createRequire(import.meta.url)('@babel/parser'); +const generated = ['docs/technical/source-map-current.md', 'docs/technical/logic-index-current.md']; +const candidates = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { encoding: 'utf8', windowsHide: true }).split('\0').filter(Boolean); +const files = []; +for (const file of [...new Set([...candidates, ...generated])].sort()) { + if (file.startsWith('.playwright-cli/') || file.startsWith('tmp/')) continue; + if (generated.includes(file) || await fs.stat(file).then((stat) => stat.isFile(), () => false)) files.push(file); +} +const link = (file, line) => `[${file}${line ? `:${line}` : ''}](../../${file.replaceAll(' ', '%20')}${line ? `#L${line}` : ''})`; +const source = ['# Current source map', '', 'Generated by `node scripts/update-technical-indexes.mjs` from tracked and unignored source paths. Baseline history remains in [source-map.md](source-map.md); behavior and test evidence live in area guides and the [implementation ledger](implementation-progress.md). Private environment contents are never read. Generated builds, dependencies and local browser artifacts are excluded.', '', '| File | Current role |', '| --- | --- |']; +const logic = ['# Current callable index', '', 'Generated from current JavaScript syntax. Includes declarations, methods and callbacks; inclusion implies no runtime coverage. Baseline history remains in [logic-index.md](logic-index.md). Regenerate after code changes.', '']; +let functions = 0, codeFiles = 0; +for (const file of files) { + const code = /\.(?:js|jsx|cjs|mjs)$/.test(file); + const role = file.startsWith('tests/') ? 'Executable test/harness' : file.startsWith('scripts/') || file.startsWith('.github/') ? 'Tooling/release' : file.startsWith('electron/') ? 'Native runtime' : file.startsWith('src/') ? 'Renderer/data/domain' : file.startsWith('docs/') ? 'Technical guidance/evidence' : 'Configuration/assets/project support'; + source.push(`| ${file.startsWith('.env') ? `\`${file}\` (contents excluded)` : link(file)} | ${role} |`); + if (!code) continue; + codeFiles++; + const ast = parse(await fs.readFile(file, 'utf8'), { sourceType: 'unambiguous', plugins: ['jsx'] }); + const entries = []; + const label = (node) => node?.name || node?.value || node?.id?.name || node?.key?.name || node?.key?.value || ''; + const visit = (node, parent) => { + if (!node || typeof node !== 'object') return; + if (['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'ObjectMethod', 'ClassMethod', 'ClassPrivateMethod'].includes(node.type)) { + const name = String(label(node) || label(parent) || `anonymous ${node.type}`).replaceAll('|', '\\|').replaceAll('`', ''); + entries.push(`- [${name}](../../${file.replaceAll(' ', '%20')}#L${node.loc.start.line}) — line ${node.loc.start.line}`); + functions++; + } + for (const [key, value] of Object.entries(node)) { + if (['loc', 'start', 'end', 'tokens', 'comments'].includes(key)) continue; + if (Array.isArray(value)) value.forEach((child) => visit(child, node)); + else if (value?.type) visit(value, node); + } + }; + visit(ast); + logic.push(`## ${file}`, '', link(file), '', ...(entries.length ? entries : ['No locally declared callable.']), ''); +} +source.splice(4, 0, `Inventory: ${files.length} files; ${codeFiles} JavaScript-family files.`, ''); +logic.splice(4, 0, `${functions} callables across ${codeFiles} code files.`, ''); +await fs.writeFile(generated[0], source.join('\n') + '\n'); +await fs.writeFile(generated[1], logic.join('\n') + '\n'); +console.log(`Indexed ${files.length} files and ${functions} callables.`); diff --git a/scripts/validate-native-package.mjs b/scripts/validate-native-package.mjs new file mode 100644 index 0000000..ebdcb6d --- /dev/null +++ b/scripts/validate-native-package.mjs @@ -0,0 +1,15 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function packagedExecutable(platform, architecture) { + if (platform === 'win32') return `release/${architecture === 'x64' ? 'win-unpacked' : `win-${architecture}-unpacked`}/Mountea Dialoguer.exe`; + if (platform === 'darwin') return `release/${architecture === 'x64' ? 'mac' : `mac-${architecture}`}/Mountea Dialoguer.app/Contents/MacOS/Mountea Dialoguer`; + if (platform === 'linux') return `release/${architecture === 'x64' ? 'linux-unpacked' : `linux-${architecture}-unpacked`}/mountea-dialoguer`; + throw new Error(`Unsupported native validation platform: ${platform}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.env.MOUNTEA_PACKAGED_EXECUTABLE ||= packagedExecutable(process.platform, process.arch); + process.env.MOUNTEA_NATIVE_EVIDENCE_PATH ||= `tmp/native-validation-${process.platform}-${process.arch}.json`; + await import('./check-electron-startup.mjs'); +} From 313210d1b1ded49603d5d293029821cb620ffa90 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:13 +0200 Subject: [PATCH 06/22] U: Update package scripts and tool configs Refresh dependencies and npm scripts; adjust Vite, ESLint and Playwright configs, add production Playwright config. --- .eslintrc.cjs | 9 +- package-lock.json | 4923 +++++++++++-------------------- package.json | 36 +- playwright.config.js | 1 + playwright.production.config.js | 14 + vite.config.js | 14 +- 6 files changed, 1740 insertions(+), 3257 deletions(-) create mode 100644 playwright.production.config.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 5bc3e5b..fd36186 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -7,10 +7,17 @@ module.exports = { 'plugin:react/jsx-runtime', 'plugin:react-hooks/recommended', ], - ignorePatterns: ['dist', '.eslintrc.cjs'], + // Disconnected pre-Zustand implementations are retained as historical fixtures. + ignorePatterns: ['dist', 'release', 'tmp', 'node_modules', 'src/helpers/**', 'src/indexedDB.js', 'src/hooks/useAutoSave.js', 'src/hooks/useAutoSaveNodesAndEdges.js'], parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, settings: { react: { version: '18.3' } }, plugins: ['react-refresh'], + overrides: [ + { + files: ['electron/**/*.{js,cjs,mjs}', 'scripts/**/*.{js,cjs,mjs}', 'tests/**/*.{js,cjs,mjs}', '*.config.{js,cjs,mjs}', '.eslintrc.cjs'], + env: { node: true }, + }, + ], rules: { 'react/prop-types': 'off', 'react-refresh/only-export-components': [ diff --git a/package-lock.json b/package-lock.json index 7b5709e..b43fb5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "mountea-dialoguer", "version": "0.2.0", "dependencies": { - "dependencies": "^0.0.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", @@ -30,7 +29,6 @@ "file-saver": "^2.0.5", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.2", - "joi": "^17.13.3", "jszip": "^3.10.1", "lucide-react": "^0.462.0", "react": "^18.3.1", @@ -44,7 +42,9 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1.58.2", + "@sentry/cli": "^2.58.6", "@sentry/react": "^10.43.0", "@tanstack/router-devtools": "^1.87.0", "@tanstack/router-plugin": "^1.87.0", @@ -54,8 +54,8 @@ "autoprefixer": "^10.4.20", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "electron": "^37.4.0", - "electron-builder": "26.0.12", + "electron": "^44.5.1", + "electron-builder": "26.15.3", "eslint": "^8.57.1", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^4.6.2", @@ -82,14 +82,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -98,9 +111,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -108,21 +121,22 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -139,14 +153,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -156,14 +170,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -173,9 +187,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -183,29 +197,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -225,9 +239,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -235,9 +249,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -245,9 +259,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -255,27 +269,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -358,33 +372,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -392,41 +406,35 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", "dev": true, - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=22.12.0" } }, "node_modules/@electron/asar": { - "version": "3.2.18", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.18.tgz", - "integrity": "sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", "dev": true, + "license": "MIT", "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", @@ -444,6 +452,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 6" } @@ -453,6 +462,7 @@ "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -467,6 +477,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, + "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -478,132 +489,32 @@ } }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "node": ">=22.12.0" }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@electron/get/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/get/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@electron/node-gyp": { - "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", - "integrity": "sha512-CrYo6TntjpoMO1SHjl5Pa/JoUsECNqNdB7Kx49WLQpWzPw53eEITJ2Hs9fh/ryUYDn4pxZz11StaBYBrLFJdqg==", - "dev": true, - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^8.1.0", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.2.1", - "nopt": "^6.0.0", - "proc-log": "^2.0.1", - "semver": "^7.3.5", - "tar": "^6.2.1", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@electron/node-gyp/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@electron/node-gyp/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" + "undici": "^7.24.4" } }, - "node_modules/@electron/node-gyp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/@electron/get/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -616,6 +527,7 @@ "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", @@ -630,6 +542,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, + "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -641,10 +554,11 @@ } }, "node_modules/@electron/osx-sign": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", - "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", @@ -666,6 +580,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -680,6 +595,7 @@ "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8.0.0" }, @@ -688,66 +604,34 @@ } }, "node_modules/@electron/rebuild": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.0.tgz", - "integrity": "sha512-VW++CNSlZwMYP7MyXEbrKjpzEwhB5kDNbzGtiPEjwYysqyTCF+YbNJ210Dj3AjWsGSV4iEEwNkmJN9yGZmVvmw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", "dev": true, + "license": "MIT", "dependencies": { - "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", - "got": "^11.7.0", - "node-abi": "^3.45.0", - "node-api-version": "^0.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" }, "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/@electron/rebuild/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@electron/rebuild/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "node": ">=22.12.0" } }, "node_modules/@electron/universal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", - "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", "dev": true, + "license": "MIT", "dependencies": { - "@electron/asar": "^3.2.7", + "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", @@ -760,10 +644,11 @@ } }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -773,6 +658,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" }, @@ -788,8 +674,8 @@ "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", "dev": true, + "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1316,96 +1202,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@fastify/otel": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", - "integrity": "sha512-2304BdM5Q/kUvQC9qJO1KZq3Zn1WWsw+WWkVmFEaj1UE2hEIiuFqrPeglQOwEtw/ftngisqfQ3v70TWMmwhhHA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.208.0", - "@opentelemetry/semantic-conventions": "^1.28.0", - "minimatch": "^10.0.3" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", - "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", - "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@fastify/otel/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@fastify/otel/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@fastify/otel/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", @@ -1444,12 +1240,6 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true - }, "node_modules/@gilbarbara/deep-equal": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz", @@ -1469,13 +1259,6 @@ "node": ">=14.0.0" } }, - "node_modules/@hapi/address/node_modules/@hapi/hoek": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", - "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@hapi/formula": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", @@ -1484,9 +1267,10 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@hapi/pinpoint": { @@ -1507,12 +1291,13 @@ } }, "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@hapi/hoek": "^9.0.0" + "@hapi/hoek": "^11.0.2" } }, "node_modules/@humanwhocodes/config-array": { @@ -1553,111 +1338,28 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, + "license": "ISC", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + "minipass": "^7.0.4" }, "engines": { - "node": ">=12" + "node": ">=18.0.0" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/remapping": { @@ -1714,6 +1416,7 @@ "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" } ], + "license": "Apache-2.0", "dependencies": { "cross-spawn": "^7.0.1" }, @@ -1726,6 +1429,7 @@ "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", @@ -1741,6 +1445,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, + "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -1751,6 +1456,36 @@ "node": ">=10" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1789,58 +1524,20 @@ "node": ">= 8" } }, - "node_modules/@npmcli/fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", - "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", - "dev": true, - "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/move-file": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", - "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", - "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -1849,23 +1546,12 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", - "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", - "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1877,13 +1563,14 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", - "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", "license": "Apache-2.0", + "peer": true, "dependencies": { - "@opentelemetry/api-logs": "0.211.0", - "import-in-the-middle": "^2.0.0", + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "engines": { @@ -1893,404 +1580,30 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz", - "integrity": "sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.54.0.tgz", - "integrity": "sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.28.0.tgz", - "integrity": "sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.59.0.tgz", - "integrity": "sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.30.0.tgz", - "integrity": "sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.54.0.tgz", - "integrity": "sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.58.0.tgz", - "integrity": "sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.57.0.tgz", - "integrity": "sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.211.0.tgz", - "integrity": "sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/instrumentation": "0.211.0", - "@opentelemetry/semantic-conventions": "^1.29.0", - "forwarded-parse": "2.1.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", - "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", - "integrity": "sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.20.0.tgz", - "integrity": "sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.55.0.tgz", - "integrity": "sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.59.0.tgz", - "integrity": "sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.55.0.tgz", - "integrity": "sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.64.0.tgz", - "integrity": "sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.57.0.tgz", - "integrity": "sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz", - "integrity": "sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/mysql": "2.15.27" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz", - "integrity": "sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@opentelemetry/sql-common": "^0.41.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.63.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.63.0.tgz", - "integrity": "sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@opentelemetry/sql-common": "^0.41.2", - "@types/pg": "8.15.6", - "@types/pg-pool": "2.0.7" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.59.0.tgz", - "integrity": "sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.30.0.tgz", - "integrity": "sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.21.0.tgz", - "integrity": "sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.24.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.7.0" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", - "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/resources": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", - "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -2301,13 +1614,15 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", - "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", + "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", "license": "Apache-2.0", + "peer": true, "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/resources": "2.6.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -2318,94 +1633,83 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", "engines": { "node": ">=14" } }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", - "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", - "license": "Apache-2.0", + "node_modules/@peculiar/asn1-schema": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^2.0.0" + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, "engines": { "node": ">=14" } }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" + "tslib": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=8.0.0" } }, - "node_modules/@prisma/instrumentation": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", - "integrity": "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==", - "license": "Apache-2.0", + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.207.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.8" + "tslib": "^2.8.1" } }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/api": "^1.3.0" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" }, "engines": { - "node": ">=8.0.0" + "node": ">=14.18.0" } }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", - "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" + "playwright": "1.58.2" }, - "engines": { - "node": "^18.19.0 || >=20.6.0" + "bin": { + "playwright": "cli.js" }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "engines": { + "node": ">=18" } }, "node_modules/@radix-ui/number": { @@ -3220,9 +2524,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", "cpu": [ "arm" ], @@ -3234,9 +2538,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", "cpu": [ "arm64" ], @@ -3248,9 +2552,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", "cpu": [ "arm64" ], @@ -3262,9 +2566,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", "cpu": [ "x64" ], @@ -3276,9 +2580,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", "cpu": [ "arm64" ], @@ -3290,9 +2594,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", "cpu": [ "x64" ], @@ -3304,9 +2608,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", "cpu": [ "arm" ], @@ -3318,9 +2622,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", "cpu": [ "arm" ], @@ -3332,9 +2636,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", "cpu": [ "arm64" ], @@ -3346,9 +2650,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", "cpu": [ "arm64" ], @@ -3360,9 +2664,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", "cpu": [ "loong64" ], @@ -3374,9 +2678,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", "cpu": [ "loong64" ], @@ -3388,9 +2692,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", "cpu": [ "ppc64" ], @@ -3402,9 +2706,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", "cpu": [ "ppc64" ], @@ -3416,9 +2720,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", "cpu": [ "riscv64" ], @@ -3430,9 +2734,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", "cpu": [ "riscv64" ], @@ -3444,9 +2748,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", "cpu": [ "s390x" ], @@ -3458,9 +2762,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", "cpu": [ "x64" ], @@ -3472,9 +2776,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", "cpu": [ "x64" ], @@ -3486,9 +2790,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", "cpu": [ "x64" ], @@ -3500,9 +2804,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", "cpu": [ "arm64" ], @@ -3514,9 +2818,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", "cpu": [ "arm64" ], @@ -3528,9 +2832,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", "cpu": [ "ia32" ], @@ -3542,9 +2846,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", "cpu": [ "x64" ], @@ -3556,9 +2860,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", "cpu": [ "x64" ], @@ -3569,93 +2873,271 @@ "win32" ] }, - "node_modules/@sentry-internal/browser-utils": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz", - "integrity": "sha512-HCEICKvepxN4/6NYfnMMMlppcSwIEwtS66X6d1/mwaHdi2ivw0uGl52p7Nfhda/lIJArbrkWprxl0WcjZajhQA==", + "node_modules/@sentry/browser": { + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.0.tgz", + "integrity": "sha512-CKGSJLWISQ6LgMaGnMpY+XvwWQ2iiHTayUs6SGLIvYeaGo2GTjS/Z4pnbCVHb2t1VxLZksyehgcC6ZYGJeQpzQ==", "license": "MIT", "dependencies": { - "@sentry/core": "10.42.0" + "@sentry/browser-utils": "10.75.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.75.0", + "@sentry/feedback": "10.75.0", + "@sentry/replay": "10.75.0", + "@sentry/replay-canvas": "10.75.0" }, "engines": { "node": ">=18" } }, - "node_modules/@sentry-internal/feedback": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.42.0.tgz", - "integrity": "sha512-lpPcHsog10MVYFTWE0Pf8vQRqQWwZHJpkVl2FEb9/HDdHFyTBUhCVoWo1KyKaG7GJl9AVKMAg7bp9SSNArhFNQ==", + "node_modules/@sentry/browser-utils": { + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.0.tgz", + "integrity": "sha512-yZfdQ87RLat/7retF8zA6Z9zCAT12IyaPvIiu+bbi1E1V1OqDpS8MKNa/6LF69Ap7DVTORi9ITEgjlAqTXF0xQ==", "license": "MIT", "dependencies": { - "@sentry/core": "10.42.0" + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" } }, - "node_modules/@sentry-internal/replay": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.42.0.tgz", - "integrity": "sha512-Zh3EoaH39x2lqVY1YyVB2vJEyCIrT+YLUQxYl1yvP0MJgLxaR6akVjkgxbSUJahan4cX5DxpZiEHfzdlWnYPyQ==", - "license": "MIT", + "node_modules/@sentry/cli": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.42.0", - "@sentry/core": "10.42.0" + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" }, "engines": { - "node": ">=18" + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.42.0.tgz", - "integrity": "sha512-am3m1Fj8ihoPfoYo41Qq4KeCAAICn4bySso8Oepu9dMNe9Lcnsf+reMRS2qxTPg3pZDc4JEMOcLyNCcgnAfrHw==", + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.42.0", - "@sentry/core": "10.42.0" + "debug": "4" }, "engines": { - "node": ">=18" + "node": ">= 6.0.0" } }, - "node_modules/@sentry/browser": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.42.0.tgz", - "integrity": "sha512-iXxYjXNEBwY1MH4lDSDZZUNjzPJDK7/YLwVIJq/3iBYpIQVIhaJsoJnf3clx9+NfJ8QFKyKfcvgae61zm+hgTA==", + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.42.0", - "@sentry-internal/feedback": "10.42.0", - "@sentry-internal/replay": "10.42.0", - "@sentry-internal/replay-canvas": "10.42.0", - "@sentry/core": "10.42.0" + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">=18" + "node": ">= 6" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" } }, "node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.0.tgz", + "integrity": "sha512-5wDQpQqjJ6RHdPR+z6Q+47XlwoxRKBv0yyB0LKHqL/1azPOQbR+nllyLmmQDcoJpaksuLSmLA1q6hFX7gjDT2w==", "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, "engines": { "node": ">=18" } }, "node_modules/@sentry/electron": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@sentry/electron/-/electron-7.10.0.tgz", - "integrity": "sha512-RwifPIBQds31giWL5KF87R/owzcVamMcXkL2ctoe/ybsxV861cbNhXxba/XCI6YmYOGIaixqiCAasxeZ+mx1SA==", + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@sentry/electron/-/electron-7.20.0.tgz", + "integrity": "sha512-K6vb4Wht4DxWFYol9KH9S/M28t2l81Je104jlFgTUplMj2Ny0SHM6Z1z5a9t8vrZGDry3NvxL90k3xgbOsOqgQ==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.42.0", - "@sentry/core": "10.42.0", - "@sentry/node": "10.42.0" + "@sentry/browser": "10.75.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.75.0", + "@sentry/node": "10.75.0" }, "peerDependencies": { - "@sentry/node-native": "10.42.0" + "@sentry/node-native": "10.75.0" }, "peerDependenciesMeta": { "@sentry/node-native": { @@ -3663,115 +3145,93 @@ } } }, + "node_modules/@sentry/feedback": { + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.0.tgz", + "integrity": "sha512-YLVAFQXELJAOg7ke4oLkd8t5l0fJUockLlTQJg/2Trh7SB5Q4mkSwlnAkEAqDQ618vnpFsG/tnYMwtfZeffHjA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.75.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/node": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.42.0.tgz", - "integrity": "sha512-ZZfU3Fnni7Aj0lTX4e3QpY3UxK4FGuzfM20316UAJycBGnripm+sDHwcekPMGfLnk/FrN9wa1atspVlHvOI0WQ==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.75.0.tgz", + "integrity": "sha512-XdYW+SEiscQnuugh1hMldfnHurmleKSTSDqgfro6Y1yd7s0r2csnZ5/SEYxIeL4lSl1QXg1lIA1pBmAXjcdnKA==", "license": "MIT", "dependencies": { - "@fastify/otel": "0.16.0", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.5.1", - "@opentelemetry/core": "^2.5.1", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/instrumentation-amqplib": "0.58.0", - "@opentelemetry/instrumentation-connect": "0.54.0", - "@opentelemetry/instrumentation-dataloader": "0.28.0", - "@opentelemetry/instrumentation-express": "0.59.0", - "@opentelemetry/instrumentation-fs": "0.30.0", - "@opentelemetry/instrumentation-generic-pool": "0.54.0", - "@opentelemetry/instrumentation-graphql": "0.58.0", - "@opentelemetry/instrumentation-hapi": "0.57.0", - "@opentelemetry/instrumentation-http": "0.211.0", - "@opentelemetry/instrumentation-ioredis": "0.59.0", - "@opentelemetry/instrumentation-kafkajs": "0.20.0", - "@opentelemetry/instrumentation-knex": "0.55.0", - "@opentelemetry/instrumentation-koa": "0.59.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.55.0", - "@opentelemetry/instrumentation-mongodb": "0.64.0", - "@opentelemetry/instrumentation-mongoose": "0.57.0", - "@opentelemetry/instrumentation-mysql": "0.57.0", - "@opentelemetry/instrumentation-mysql2": "0.57.0", - "@opentelemetry/instrumentation-pg": "0.63.0", - "@opentelemetry/instrumentation-redis": "0.59.0", - "@opentelemetry/instrumentation-tedious": "0.30.0", - "@opentelemetry/instrumentation-undici": "0.21.0", - "@opentelemetry/resources": "^2.5.1", - "@opentelemetry/sdk-trace-base": "^2.5.1", - "@opentelemetry/semantic-conventions": "^1.39.0", - "@prisma/instrumentation": "7.2.0", - "@sentry/core": "10.42.0", - "@sentry/node-core": "10.42.0", - "@sentry/opentelemetry": "10.42.0", - "import-in-the-middle": "^2.0.6" + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.75.0", + "@sentry/node-core": "10.75.0", + "@sentry/opentelemetry": "10.75.0", + "@sentry/server-utils": "10.75.0", + "import-in-the-middle": "^3.0.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/node-core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.42.0.tgz", - "integrity": "sha512-9tf3fPV6M071aps72D+PEtdQPTuj+SuqO2+PpTfdPP5ZL4TTKYo3VK0li76SL+5wGdTFGV5qmsokHq9IRBA0iA==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.75.0.tgz", + "integrity": "sha512-+E3KSX1oMqhpWQ23hj6NblIVUzLy3T2oceU7DfMX1s1ar+npaQZxR5K0/cMGcQgS1LLm8Jqwqo/lrZlKTCDm5A==", "license": "MIT", "dependencies": { - "@sentry/core": "10.42.0", - "@sentry/opentelemetry": "10.42.0", - "import-in-the-middle": "^2.0.6" + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.75.0", + "@sentry/opentelemetry": "10.75.0", + "import-in-the-middle": "^3.0.0" }, "engines": { "node": ">=18" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/resources": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { "optional": true }, - "@opentelemetry/context-async-hooks": { - "optional": true - }, "@opentelemetry/core": { "optional": true }, - "@opentelemetry/instrumentation": { + "@opentelemetry/exporter-trace-otlp-http": { "optional": true }, - "@opentelemetry/resources": { + "@opentelemetry/instrumentation": { "optional": true }, "@opentelemetry/sdk-trace-base": { "optional": true - }, - "@opentelemetry/semantic-conventions": { - "optional": true } } }, "node_modules/@sentry/opentelemetry": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.42.0.tgz", - "integrity": "sha512-5vsYz683iihzlIj3sT1+tEixf0awwXK86a+aYsnMHrTXJDrkBDq4U0ZT+yxdPfJlkaxRtYycFR08SXr2pSm7Eg==", + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.75.0.tgz", + "integrity": "sha512-reJoMtuHMuaiztoDVoaeitc22eHlTAvz2qpFhibWyzukJlF8oXB+o8EvDN9mmDDMedU89c//cXRcnPBlJvUxcw==", "license": "MIT", "dependencies": { - "@sentry/core": "10.42.0" + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.75.0" }, "engines": { "node": ">=18" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "node_modules/@sentry/react": { @@ -3872,26 +3332,44 @@ "node": ">=18" } }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "license": "BSD-3-Clause", + "node_modules/@sentry/replay": { + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.0.tgz", + "integrity": "sha512-p4xxEo/ayiMZG9tygr09YjqYAd52uq8Qi0EVoRRgUe70nQda0HVPVW519JLq0G+Th/5O5sKME9y1layLU3xlPg==", + "license": "MIT", "dependencies": { - "@hapi/hoek": "^9.0.0" + "@sentry/browser-utils": "10.75.0", + "@sentry/core": "10.75.0" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "license": "BSD-3-Clause" + "node_modules/@sentry/replay-canvas": { + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.0.tgz", + "integrity": "sha512-LNokI5Ud22nZ/o2il/2SfbNIARupWrbS6Mh9jZroZTjD38FN0Oi9vIexj6h1HN81C6+lP2Tmpaj+fl3/wOizzA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.75.0", + "@sentry/replay": "10.75.0" + }, + "engines": { + "node": ">=18" + } }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "license": "BSD-3-Clause" + "node_modules/@sentry/server-utils": { + "version": "10.75.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.75.0.tgz", + "integrity": "sha512-7fIa9vFGNzB13hmxl8Sip3xImSkqpc4wETzuQ7CLzVOwPvwI6y/gVEm0pxRhtJSq8SzfOp26eJWl80u8v78Osg==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.75.0" + }, + "engines": { + "node": ">=18" + } }, "node_modules/@sindresorhus/is": { "version": "4.6.0", @@ -3944,6 +3422,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.157.18.tgz", "integrity": "sha512-qs//HcVhEZ0K2/Sqejol0vOWaFIh4EoYTQQix9FhHOyWvdUpGoTJS0+g/qxEnZZm7r9QNOrnyrYZ5CDAqnII6g==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", @@ -4015,6 +3494,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.157.18.tgz", "integrity": "sha512-jGkyA3EEE01Sf6d4goi//poxQNb/Odc/GzpjZSW2zwG+wcXm9hEzcI6vU2IxhAU0dvvwQyQgtU1HXTcXQ/Xg4A==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", @@ -4215,15 +3695,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "engines": { - "node": ">= 10" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4282,15 +3753,6 @@ "@types/responselike": "^1.0.0" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -4341,18 +3803,19 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dev": true, + "license": "MIT", "dependencies": { "@types/ms": "*" } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -4361,6 +3824,7 @@ "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -4386,55 +3850,16 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true - }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", "license": "MIT", "dependencies": { - "@types/pg": "*" - } - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/prop-types": { @@ -4448,6 +3873,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4459,6 +3885,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4473,33 +3900,6 @@ "@types/node": "*" } }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "optional": true - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -4529,10 +3929,11 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.0.0" } @@ -4597,23 +3998,23 @@ "d3-zoom": "^3.0.0" } }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true - }, "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4621,15 +4022,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -4637,47 +4029,24 @@ "dev": true, "license": "MIT", "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 14" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4690,15 +4059,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4756,58 +4116,145 @@ "node": ">= 8" } }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true - }, "node_modules/app-builder-lib": { - "version": "26.0.12", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.0.12.tgz", - "integrity": "sha512-+/CEPH1fVKf6HowBUs6LcAIoRcjeqgvAeoSE+cl7Y7LndyQ9ViGPYibNk7wmhMHzNgHIuIbw4nWADPO+4mjgWw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", "dev": true, + "license": "MIT", "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/asar": "3.2.18", + "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.1", - "@electron/rebuild": "3.7.0", - "@electron/universal": "2.0.1", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.0.11", - "builder-util-runtime": "9.3.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", - "config-file-ts": "0.2.8-rc1", + "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.0.11", + "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", - "is-ci": "^3.0.0", "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", - "minimatch": "^10.0.0", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", "plist": "3.1.0", + "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", - "semver": "^7.3.8", - "tar": "^6.1.12", + "semver": "~7.7.3", + "tar": "^7.5.7", "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.0.12", - "electron-builder-squirrel-windows": "26.0.12" + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/app-builder-lib/node_modules/balanced-match": { @@ -4815,20 +4262,48 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, + "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, "node_modules/app-builder-lib/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/app-builder-lib/node_modules/fs-extra": { @@ -4836,6 +4311,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -4845,13 +4321,41 @@ "node": ">=12" } }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/app-builder-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -4865,6 +4369,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -4872,6 +4377,22 @@ "node": ">=10" } }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -5046,22 +4567,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "license": "MIT", + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "license": "MIT", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, "engines": { - "node": ">=0.8" + "node": ">=12.0.0" } }, "node_modules/ast-types": { @@ -5077,20 +4595,11 @@ "node": ">=4" } }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, "license": "MIT" }, "node_modules/async-exit-hook": { @@ -5098,6 +4607,7 @@ "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -5116,6 +4626,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5123,6 +4634,7 @@ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true, + "license": "ISC", "engines": { "node": ">= 4.0.0" } @@ -5180,50 +4692,73 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, - "node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" }, "engines": { "node": ">= 6" } }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -5262,25 +4797,20 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "license": "BSD-3-Clause", - "dependencies": { - "tweetnacl": "^0.14.3" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -5296,30 +4826,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } + "license": "MIT" }, "node_modules/boolean": { "version": "3.2.0", @@ -5331,9 +4843,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5355,9 +4867,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -5374,90 +4886,60 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", + "browserslist": "cli.js" + }, "engines": { - "node": "*" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/builder-util": { - "version": "26.0.11", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.0.11.tgz", - "integrity": "sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", "dev": true, + "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.3.1", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", - "is-ci": "^3.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/builder-util-runtime": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", - "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -5471,6 +4953,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5480,83 +4963,14 @@ "node": ">=12" } }, - "node_modules/cacache": { - "version": "16.1.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", - "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", - "dev": true, - "dependencies": { - "@npmcli/fs": "^2.1.0", - "@npmcli/move-file": "^2.0.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "glob": "^8.0.1", - "infer-owner": "^1.0.4", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^9.0.0", - "tar": "^6.1.11", - "unique-filename": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=10" + "node": ">=6.0.0" } }, "node_modules/cacheable-lookup": { @@ -5659,9 +5073,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -5689,12 +5103,6 @@ "url": "https://www.paypal.me/kirilvatev" } }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "license": "Apache-2.0" - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5738,24 +5146,26 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/chromium-pickle-js": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -5763,14 +5173,15 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", "license": "MIT" }, "node_modules/classcat": { @@ -5779,56 +5190,6 @@ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5844,15 +5205,6 @@ "node": ">=12" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", @@ -5915,6 +5267,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -5945,6 +5298,7 @@ "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5957,15 +5311,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -5997,70 +5351,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/config-file-ts": { - "version": "0.2.8-rc1", - "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", - "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", - "dev": true, - "dependencies": { - "glob": "^10.3.12", - "typescript": "^5.4.3" - } - }, - "node_modules/config-file-ts/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/config-file-ts/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/config-file-ts/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/config-file-ts/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6080,23 +5370,13 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, - "optional": true, - "peer": true + "license": "MIT", + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -6148,7 +5428,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-color": { "version": "3.1.0", @@ -6207,6 +5488,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6264,18 +5546,6 @@ "lodash": "^4.17.15" } }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6399,18 +5669,6 @@ "node": ">=0.10.0" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -6461,32 +5719,12 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" } }, - "node_modules/dependencies": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/dependencies/-/dependencies-0.0.1.tgz", - "integrity": "sha512-geDgiSknEUQ3IV0hr8EnkrZRdgdybxMzySyWRE8P/TE4teCV7g1rRrHAjd6TdlOLIkZVQm+tZU9AxdLv/m57kg==", - "license": "BSD", - "dependencies": { - "async": "*", - "node-walker": "*", - "request": "*", - "underscore": "*" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -6505,7 +5743,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.3.0.tgz", "integrity": "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/dexie-react-hooks": { "version": "1.1.7", @@ -6540,6 +5779,7 @@ "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", "dev": true, + "license": "MIT", "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " @@ -6566,20 +5806,17 @@ "license": "MIT" }, "node_modules/dmg-builder": { - "version": "26.0.12", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.0.12.tgz", - "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "app-builder-lib": "26.0.12", - "builder-util": "26.0.11", - "builder-util-runtime": "9.3.1", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" } }, "node_modules/dmg-builder/node_modules/fs-extra": { @@ -6587,6 +5824,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -6596,32 +5834,6 @@ "node": ">=12" } }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -6640,6 +5852,7 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -6652,6 +5865,7 @@ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "dotenv": "^16.4.5" }, @@ -6677,20 +5891,14 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "license": "MIT", + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" + "readable-stream": "^2.0.2" } }, "node_modules/ejs": { @@ -6698,6 +5906,7 @@ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" }, @@ -6709,36 +5918,38 @@ } }, "node_modules/electron": { - "version": "37.4.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-37.4.0.tgz", - "integrity": "sha512-HhsSdWowE5ODOeWNc/323Ug2C52mq/TqNBG+4uMeOA3G2dMXNc/nfyi0RYu1rJEgiaJLEjtHveeZZaYRYFsFCQ==", + "version": "44.5.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.5.1.tgz", + "integrity": "sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==", "dev": true, - "hasInstallScript": true, + "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { - "version": "26.0.12", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.0.12.tgz", - "integrity": "sha512-cD1kz5g2sgPTMFHjLxfMjUK5JABq3//J4jPswi93tOPFz6btzXYtK5NrDt717NRbukCUDOrrvmYVOWERlqoiXA==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", "dev": true, + "license": "MIT", "dependencies": { - "app-builder-lib": "26.0.12", - "builder-util": "26.0.11", - "builder-util-runtime": "9.3.1", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", - "dmg-builder": "26.0.12", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", - "is-ci": "^3.0.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" @@ -6752,14 +5963,15 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.0.12", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.0.12.tgz", - "integrity": "sha512-kpwXM7c/ayRUbYVErQbsZ0nQZX4aLHQrPEG9C4h9vuJCXylwFH8a7Jgi2VpKIObzCXO7LKHiCw4KdioFLFOgqA==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.0.12", - "builder-util": "26.0.11", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, @@ -6779,42 +5991,29 @@ } }, "node_modules/electron-publish": { - "version": "26.0.11", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.0.11.tgz", - "integrity": "sha512-a8QRH0rAPIWH9WyyS5LbNvW9Ark6qe63/LqDB7vu2JXYpi0Gma5Q60Dh4tmTqhOBQt0xsrzD8qE7C+D7j+B24A==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "26.0.11", - "builder-util-runtime": "9.3.1", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", - "form-data": "^4.0.0", + "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, - "node_modules/electron-publish/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/electron-publish/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -6825,9 +6024,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.283", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", - "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "version": "1.5.443", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.443.tgz", + "integrity": "sha512-TDJG36L9A3CWWwZ97HKaE+Iz1sW80pNp6sAU0owzCIV3Zc9eVyRqZK8Vz6NKuMIU8WjP0pIkoqCa0VRf/yFD0Q==", "dev": true, "license": "ISC" }, @@ -6837,7 +6036,7 @@ "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", "dev": true, "hasInstallScript": true, - "peer": true, + "license": "MIT", "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6857,7 +6056,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, - "peer": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6872,7 +6071,7 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, - "peer": true, + "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -6882,7 +6081,7 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, - "peer": true, + "license": "MIT", "engines": { "node": ">= 4.0.0" } @@ -6898,7 +6097,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -6938,16 +6138,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6959,20 +6149,24 @@ } }, "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/es-abstract": { "version": "1.24.1", @@ -7091,6 +6285,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.2.tgz", + "integrity": "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==", + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7231,6 +6431,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7475,48 +6676,14 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT" + "license": "Apache-2.0" }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -7540,6 +6707,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -7549,6 +6717,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7559,16 +6744,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -7593,15 +6768,17 @@ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -7611,6 +6788,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -7710,16 +6888,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -7753,63 +6931,23 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { - "node": ">= 0.12" + "node": ">= 6" } }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -7839,18 +6977,6 @@ "node": ">=14.14" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -8039,15 +7165,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, "node_modules/gh-pages": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz", @@ -8126,9 +7243,9 @@ } }, "node_modules/global-agent/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "optional": true, @@ -8264,29 +7381,6 @@ "lodash": "^4.17.15" } }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", - "license": "ISC", - "engines": { - "node": ">=4" - } - }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8369,9 +7463,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -8386,6 +7480,7 @@ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", "dev": true, + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -8398,6 +7493,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -8409,7 +7505,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/html-parse-stringify": { "version": "3.0.1", @@ -8432,6 +7529,7 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -8440,21 +7538,6 @@ "node": ">= 14" } }, - "node_modules/http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - }, - "engines": { - "node": ">=0.8", - "npm": ">=1.3.7" - } - }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -8474,6 +7557,7 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, + "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -8482,15 +7566,6 @@ "node": ">= 14" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/i18next": { "version": "24.2.3", "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", @@ -8510,6 +7585,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.10" }, @@ -8531,55 +7607,6 @@ "@babel/runtime": "^7.23.2" } }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8614,15 +7641,17 @@ } }, "node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.2.tgz", + "integrity": "sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==", "license": "Apache-2.0", "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" } }, "node_modules/imurmurhash": { @@ -8635,21 +7664,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8683,15 +7697,6 @@ "node": ">= 0.4" } }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "engines": { - "node": ">= 12" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8789,18 +7794,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -8921,21 +7914,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true - }, "node_modules/is-lite": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz", @@ -9104,24 +8082,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT" - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -9179,6 +8139,7 @@ "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 18.0.0" }, @@ -9202,12 +8163,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "license": "MIT" - }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9226,26 +8181,12 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", @@ -9264,21 +8205,28 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" } }, "node_modules/js-tokens": { @@ -9288,10 +8236,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -9300,12 +8258,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "license": "MIT" - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9326,16 +8278,11 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -9349,7 +8296,9 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" + "dev": true, + "license": "ISC", + "optional": true }, "node_modules/json5": { "version": "2.2.3", @@ -9377,21 +8326,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "license": "MIT", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -9434,7 +8368,8 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/levn": { "version": "0.4.1", @@ -9496,9 +8431,10 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -9507,22 +8443,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9580,81 +8500,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-fetch-happen": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", - "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", - "dev": true, - "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^16.1.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.3", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^9.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -9708,6 +8553,7 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -9719,6 +8565,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9728,6 +8575,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -9736,15 +8584,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -9756,9 +8595,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -9779,117 +8618,39 @@ } }, "node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", - "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", - "dev": true, - "dependencies": { - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, + "license": "MIT", "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 8" + "node": ">= 18" } }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, "bin": { "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/module-details-from-path": { @@ -9917,9 +8678,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -9942,32 +8703,25 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", - "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", + "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", "dev": true, + "license": "MIT", "dependencies": { - "semver": "^7.3.5" + "semver": "^7.6.3" }, "engines": { - "node": ">=10" + "node": ">=22.12.0" } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -9975,27 +8729,22 @@ "node": ">=10" } }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "optional": true - }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.3.5" } }, "node_modules/node-api-version/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -10003,34 +8752,142 @@ "node": ">=10" } }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true, "license": "MIT" }, - "node_modules/node-walker": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/node-walker/-/node-walker-0.1.0.tgz", - "integrity": "sha512-oC1skUd/BuOroGKhTnUJn1B20ETn9SFaHjRRfS911XcdQxTlzDEWTaK0KJpgluI66tHvFBC3QnN5x7YlZb5SeQ==", + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.6.0" + "node": ">=18" } }, "node_modules/nopt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", - "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, + "license": "ISC", "dependencies": { - "abbrev": "^1.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -10056,15 +8913,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10192,21 +9040,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10225,29 +9058,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -10308,21 +9118,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -10333,12 +9128,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -10395,37 +9184,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/path-scurry/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10448,6 +9206,7 @@ "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12", "npm": ">=6" @@ -10457,50 +9216,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10509,9 +9224,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -10610,6 +9325,40 @@ "node": ">=8" } }, + "node_modules/pkijs": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], + "dependencies": { + "@noble/hashes": "1.8.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -10635,6 +9384,7 @@ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -10662,6 +9412,7 @@ "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", "dev": true, + "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", @@ -10693,9 +9444,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -10712,8 +9463,9 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10883,9 +9635,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10903,52 +9655,13 @@ "dev": true, "license": "MIT" }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/postject": { "version": "1.0.0-alpha.6", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", "dev": true, + "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10964,8 +9677,8 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", "dev": true, + "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -10997,12 +9710,13 @@ } }, "node_modules/proc-log": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", - "integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, + "license": "ISC", "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/process-nextick-args": { @@ -11021,17 +9735,12 @@ "node": ">=0.4.0" } }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "dev": true, + "license": "MIT", "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -11051,6 +9760,18 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -11058,18 +9779,6 @@ "dev": true, "license": "MIT" }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -11085,18 +9794,30 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "license": "BSD-3-Clause", + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.6" + "node": ">=16.0.0" } }, "node_modules/queue-microtask": { @@ -11138,6 +9859,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11150,6 +9872,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11358,6 +10081,7 @@ "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.3.4" }, @@ -11474,48 +10198,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", - "license": "Apache-2.0", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/request/node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "license": "MIT", - "bin": { - "uuid": "bin/uuid" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -11526,6 +10208,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-in-the-middle": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", @@ -11544,6 +10236,7 @@ "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", "dev": true, + "license": "MIT", "dependencies": { "pe-library": "^0.4.1" }, @@ -11614,24 +10307,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } @@ -11684,13 +10365,13 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -11700,31 +10381,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", "fsevents": "~2.3.2" } }, @@ -11837,26 +10519,22 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", "dev": true, + "license": "WTFPL OR ISC", "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "node_modules/sax": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", - "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" } @@ -11932,10 +10610,11 @@ } }, "node_modules/seroval": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", - "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.8.tgz", + "integrity": "sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -12031,9 +10710,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -12123,107 +10802,43 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } + "license": "ISC" }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, + "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" + "semver": "^7.5.3" }, "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" + "node": ">=10" } }, - "node_modules/socks-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", - "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 10" + "node": ">=10" } }, - "node_modules/socks-proxy-agent/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, - "dependencies": { - "debug": "4" - }, + "license": "MIT", "engines": { - "node": ">= 6.0.0" + "node": ">=8" } }, "node_modules/source-map": { @@ -12251,6 +10866,7 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -12261,6 +10877,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -12273,48 +10890,12 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "license": "MIT", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ssri": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", - "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", - "dev": true, - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 6" } @@ -12369,21 +10950,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -12495,19 +11061,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12709,44 +11262,38 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/tar/node_modules/minipass": { + "node_modules/tar/node_modules/yallist": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, - "peer": true, + "license": "MIT", "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12760,6 +11307,7 @@ "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", "dev": true, + "license": "MIT", "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" @@ -12770,6 +11318,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -12779,26 +11328,13 @@ "node": ">=12" } }, - "node_modules/temp/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "peer": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/temp/node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "peer": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -12841,6 +11377,7 @@ "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^5.5.0" } @@ -12850,6 +11387,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver" } @@ -12902,11 +11440,12 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12915,10 +11454,11 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.14" } @@ -12928,6 +11468,7 @@ "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", "dev": true, + "license": "MIT", "dependencies": { "tmp": "^0.2.0" } @@ -12945,18 +11486,12 @@ "node": ">=8.0" } }, - "node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=0.8" - } + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" }, "node_modules/tree-changes": { "version": "0.11.3", @@ -13006,6 +11541,7 @@ "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", "dev": true, + "license": "WTFPL", "dependencies": { "utf8-byte-length": "^1.0.1" } @@ -13043,24 +11579,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "license": "Unlicense" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13165,19 +11683,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -13197,41 +11702,22 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/unique-filename": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", - "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, - "dependencies": { - "unique-slug": "^3.0.0" - }, + "license": "MIT", + "optional": true, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=20.18.1" } }, - "node_modules/unique-slug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", - "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", @@ -13260,9 +11746,9 @@ } }, "node_modules/unplugin/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -13272,10 +11758,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -13307,6 +11822,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -13368,7 +11884,8 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", - "dev": true + "dev": true, + "license": "(WTFPL OR MIT)" }, "node_modules/util-deprecate": { "version": "1.0.2", @@ -13377,9 +11894,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -13402,32 +11919,13 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/verror/node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "license": "MIT" - }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14000,11 +12498,12 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14041,50 +12540,26 @@ "node": ">=20.0.0" } }, - "node_modules/wait-on/node_modules/@hapi/hoek": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", - "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/wait-on/node_modules/@hapi/topo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", - "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^11.0.2" - } - }, - "node_modules/wait-on/node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@hapi/address": "^5.1.1", - "@hapi/formula": "^3.0.2", - "@hapi/hoek": "^11.0.7", - "@hapi/pinpoint": "^2.0.1", - "@hapi/tlds": "^1.1.1", - "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" - }, - "engines": { - "node": ">= 20" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true, - "dependencies": { - "defaults": "^1.0.3" - } + "license": "BSD-2-Clause" }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", @@ -14093,6 +12568,17 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14233,24 +12719,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -14263,17 +12731,9 @@ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", "dev": true, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", "engines": { - "node": ">=0.4" + "node": ">=8.0" } }, "node_modules/y18n": { @@ -14322,17 +12782,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 822394b..73485de 100644 --- a/package.json +++ b/package.json @@ -17,20 +17,23 @@ "build:steam": "cross-env VITE_DIST_CHANNEL=steam STEAM_APP_ID=4509320 vite build", "preview": "vite preview", "electron:start": "electron .", - "electron:pack": "npm run build && node scripts/run-electron-builder.mjs --dir", - "electron:pack:steam": "npm run build:steam && cross-env VITE_DIST_CHANNEL=steam STEAM_APP_ID=4509320 node scripts/run-electron-builder.mjs --dir --config.extraMetadata.mounteaDistChannel=steam --config.extraMetadata.mounteaSteamAppId=4509320", - "electron:dist": "npm run build && node scripts/run-electron-builder.mjs", - "electron:dist:steam": "npm run build:steam && cross-env VITE_DIST_CHANNEL=steam STEAM_APP_ID=4509320 node scripts/run-electron-builder.mjs --config.extraMetadata.mounteaDistChannel=steam --config.extraMetadata.mounteaSteamAppId=4509320", - "steam:upload:mac": "./steam_build_upload_mac.sh", - "lint": "eslint src --ext js,jsx", - "lint:ci": "eslint src/App.test.js src/components/dialogs/EditCategoryDialog.jsx src/components/projects/sections/OverviewSection.jsx src/components/ui/command.jsx src/hooks/useAutoSave.js src/indexedDB.js src/routes/projects/*/dialogue/*/index.jsx src/main.jsx src/lib/monitoring/sentry.js src/components/ui/AppErrorBoundary.jsx src/stores/projectStore.js src/lib/sync/core/syncActions.js tests/e2e/smoke.spec.js tests/e2e/evidence.spec.js tests/e2e/helpers/appHarness.js", + "electron:pack": "node scripts/run-electron-builder.mjs --dir", + "electron:pack:steam": "cross-env VITE_DIST_CHANNEL=steam STEAM_APP_ID=4509320 node scripts/run-electron-builder.mjs --dir --config.extraMetadata.mounteaDistChannel=steam --config.extraMetadata.mounteaSteamAppId=4509320", + "electron:dist": "node scripts/run-electron-builder.mjs --public-release", + "electron:dist:steam": "cross-env VITE_DIST_CHANNEL=steam STEAM_APP_ID=4509320 node scripts/run-electron-builder.mjs --public-release --config.extraMetadata.mounteaDistChannel=steam --config.extraMetadata.mounteaSteamAppId=4509320", + "steam:upload:mac": "node scripts/steam-upload.mjs --platform macos", + "lint": "eslint src electron scripts tests *.config.js .eslintrc.cjs --ext js,jsx,cjs,mjs", + "lint:ci": "npm run lint", "test:e2e": "playwright test", "test:e2e:smoke": "playwright test tests/e2e/smoke.spec.js", "test:e2e:evidence": "npx playwright test tests/e2e/evidence.spec.js --reporter=list --workers=1", - "validate:skills": "npm run lint:ci && npm run build && npm run test:e2e:smoke", - "predeploy": "npm run build", - "deploy": "gh-pages -d dist", - "steam:upload:linux": "./steam_build_upload_linux.sh" + "validate:skills": "npm run lint:ci && npm run test:regressions && npm run build && npm run validate:artifact", + "steam:upload:linux": "node scripts/steam-upload.mjs --platform linux", + "test:regressions": "playwright test regressions --workers=1", + "test:e2e:production": "playwright test --config playwright.production.config.js", + "validate:artifact": "node scripts/release-artifact.mjs finalize && npm run test:e2e:production && node scripts/release-artifact.mjs attest", + "validate:native-package": "node scripts/validate-native-package.mjs", + "steam:upload": "node scripts/steam-upload.mjs" }, "build": { "appId": "com.mountea.dialoguer", @@ -48,7 +51,8 @@ "dist/**/*", "electron/**/*", "node_modules/steamworks.js/**/*", - "package.json" + "package.json", + "!dist/**/*.map" ], "win": { "target": [ @@ -95,7 +99,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dagre": "^0.8.5", - "dependencies": "^0.0.1", "dexie": "^4.0.11", "dexie-react-hooks": "^1.1.7", "embla-carousel-autoplay": "^8.6.0", @@ -103,7 +106,6 @@ "file-saver": "^2.0.5", "i18next": "^24.2.0", "i18next-browser-languagedetector": "^8.0.2", - "joi": "^17.13.3", "jszip": "^3.10.1", "lucide-react": "^0.462.0", "react": "^18.3.1", @@ -117,7 +119,9 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1.58.2", + "@sentry/cli": "^2.58.6", "@sentry/react": "^10.43.0", "@tanstack/router-devtools": "^1.87.0", "@tanstack/router-plugin": "^1.87.0", @@ -127,8 +131,8 @@ "autoprefixer": "^10.4.20", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "electron": "^37.4.0", - "electron-builder": "26.0.12", + "electron": "^44.5.1", + "electron-builder": "26.15.3", "eslint": "^8.57.1", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^4.6.2", diff --git a/playwright.config.js b/playwright.config.js index 53953a1..577037b 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -11,6 +11,7 @@ export default defineConfig({ }, fullyParallel: false, retries: process.env.CI ? 2 : 0, + failOnFlakyTests: Boolean(process.env.CI), workers: process.env.CI ? 1 : undefined, reporter: process.env.CI ? [['line'], ['html', { open: 'never', outputFolder: 'tmp/playwright-report' }]] diff --git a/playwright.production.config.js b/playwright.production.config.js new file mode 100644 index 0000000..72e99ee --- /dev/null +++ b/playwright.production.config.js @@ -0,0 +1,14 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = Number(process.env.PLAYWRIGHT_PRODUCTION_PORT || 4195); +export default defineConfig({ + testDir: './tests/e2e', + testMatch: ['smoke.spec.js', 'production-artifact.spec.js', 'product-production.spec.js'], + timeout: 90000, + workers: 1, + retries: 0, + use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + projects: [{ name: 'production-chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { command: `node scripts/release-artifact.mjs verify dist --unvalidated && npm run preview -- --host 127.0.0.1 --port ${port} --strictPort`, url: `http://127.0.0.1:${port}`, reuseExistingServer: false, timeout: 30000 }, + outputDir: 'tmp/production-results', +}); diff --git a/vite.config.js b/vite.config.js index e0d02ee..8b3987f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,17 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; import path from 'path'; +import { readFileSync } from 'node:fs'; +import { productBuildPlugin, resolveReleaseIdentity } from './scripts/product-build.mjs'; -export default defineConfig({ +const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); +const release = resolveReleaseIdentity({ version }); + +export default defineConfig(({ mode }) => ({ + define: { __APP_RELEASE__: JSON.stringify(release) }, plugins: [ + productBuildPlugin(release, Boolean(String(loadEnv(mode, process.cwd()).VITE_SENTRY_DSN || '').trim())), TanStackRouterVite({ autoCodeSplitting: true, }), @@ -17,6 +24,7 @@ export default defineConfig({ }, base: './', build: { + sourcemap: 'hidden', outDir: 'dist', rollupOptions: { output: { @@ -36,4 +44,4 @@ export default defineConfig({ }, }, }, -}); +})); From f29d02e097ad728264fa863c1af7310e92855381 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:13 +0200 Subject: [PATCH 07/22] U: Rework CI and deploy workflows CI runs lint, regressions, production validation and native startup; deploy publishes the validated artifact. --- .github/workflows/ci.yml | 180 +++++++++++++++++++++++++++++------ .github/workflows/deploy.yml | 65 +++++++------ 2 files changed, 191 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 397a009..f65a038 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,37 +1,163 @@ name: CI - on: push: - branches: - - master + branches: [master, 'validation/**'] pull_request: workflow_dispatch: - +permissions: + contents: read jobs: quality: - runs-on: ubuntu-latest - timeout-minutes: 30 + runs-on: ubuntu-22.04 + timeout-minutes: 45 steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Install Playwright browser - run: npx playwright install --with-deps chromium - - - name: Lint - run: npm run lint:ci - - - name: Build + node-version: '24' + cache: npm + - run: npm ci + - name: Audit shipped runtime and build dependencies + run: | + mkdir -p tmp + npm audit --audit-level=high --json > tmp/dependency-audit.json + - run: npx playwright install --with-deps chromium + - run: npm run lint:ci + - run: npm run test:regressions + - name: Build the renderer once run: npm run build - - - name: Smoke E2E - run: npm run test:e2e:smoke + env: + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + - name: Finalize private maps and validate exact production files + run: npm run validate:artifact + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + - uses: actions/upload-artifact@v4 + with: + name: validated-renderer-${{ github.sha }} + path: dist + if-no-files-found: error + retention-days: 14 + - name: Preserve renderer validation evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: quality-evidence-${{ github.sha }} + path: | + tmp/dependency-audit.json + tmp/playwright-report + tmp/playwright-results + tmp/production-results + dist/release.json + dist/artifact-integrity.json + dist/artifact-validation.json + if-no-files-found: warn + retention-days: 14 + native: + name: Unsigned package (${{ matrix.target }}) + needs: quality + strategy: + fail-fast: false + matrix: + include: + - target: windows-x64 + os: windows-2022 + platform: win32 + arch: x64 + - target: macos-intel + os: macos-15-intel + platform: darwin + arch: x64 + - target: macos-arm64 + os: macos-15 + platform: darwin + arch: arm64 + - target: linux-x64 + os: ubuntu-22.04 + platform: linux + arch: x64 + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + env: + MOUNTEA_EXPECTED_PLATFORM: ${{ matrix.platform }} + MOUNTEA_EXPECTED_ARCH: ${{ matrix.arch }} + MOUNTEA_NATIVE_EVIDENCE_PATH: tmp/native-evidence/startup.json + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + architecture: ${{ matrix.arch }} + cache: npm + - name: Record runner identity + shell: bash + run: | + mkdir -p tmp/native-evidence + node -e 'const fs=require("node:fs"),os=require("node:os"); fs.writeFileSync("tmp/native-evidence/host.json",JSON.stringify({platform:process.platform,arch:process.arch,node:process.version,os:os.release(),commit:process.env.GITHUB_SHA,runnerImage:process.env.ImageOS,runnerVersion:process.env.ImageVersion},null,2)); if(process.platform!==process.env.MOUNTEA_EXPECTED_PLATFORM||process.arch!==process.env.MOUNTEA_EXPECTED_ARCH) process.exit(1)' + - run: npm ci + - name: Install Linux display and runtime libraries + if: runner.os == 'Linux' + run: | + npx playwright install-deps chromium + sudo apt-get install -y xvfb xauth libgtk-3-0 libnss3 libasound2 libgbm1 + - name: Install platform browser + run: npx playwright install chromium + - name: Run platform module regressions + shell: bash + run: npm run test:regressions 2>&1 | tee tmp/native-evidence/regressions.log + - uses: actions/download-artifact@v4 + with: + name: validated-renderer-${{ github.sha }} + path: dist + - run: node scripts/release-artifact.mjs verify + - name: Package the tested renderer without rebuilding + shell: bash + run: npm run electron:pack -- --${{ matrix.arch }} 2>&1 | tee tmp/native-evidence/package.log + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + - name: Configure packaged Linux sandbox + if: runner.os == 'Linux' + shell: bash + run: | + test -f release/linux-unpacked/chrome-sandbox + sudo chown root:root release/linux-unpacked/chrome-sandbox + sudo chmod 4755 release/linux-unpacked/chrome-sandbox + stat -c '%U:%G %a %n' release/linux-unpacked/chrome-sandbox > tmp/native-evidence/sandbox.txt + - name: Validate actual packaged startup + shell: bash + run: | + if [ "$RUNNER_OS" = Linux ]; then + xvfb-run --auto-servernum npm run validate:native-package 2>&1 | tee tmp/native-evidence/startup.log + else + npm run validate:native-package 2>&1 | tee tmp/native-evidence/startup.log + fi + - run: node scripts/release-artifact.mjs verify + - name: Validate the shared production renderer on this platform + shell: bash + run: npm run test:e2e:production 2>&1 | tee tmp/native-evidence/production.log + - run: node scripts/release-artifact.mjs verify + - name: Record gate outcome + if: always() + shell: bash + env: + NATIVE_JOB_STATUS: ${{ job.status }} + run: | + mkdir -p tmp/native-evidence + node -e 'require("node:fs").writeFileSync("tmp/native-evidence/outcome.json",JSON.stringify({status:process.env.NATIVE_JOB_STATUS,gate:"unsigned-unpacked",platform:process.env.MOUNTEA_EXPECTED_PLATFORM,arch:process.env.MOUNTEA_EXPECTED_ARCH,commit:process.env.GITHUB_SHA},null,2))' + - name: Preserve native evidence even on failure + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-evidence-${{ matrix.target }}-${{ github.sha }} + path: | + tmp/native-evidence + tmp/playwright-report + tmp/playwright-results + tmp/production-results + dist/release.json + dist/artifact-integrity.json + dist/artifact-validation.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a87e142..25ffc31 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,31 +1,42 @@ -name: Deploy to GitHub Pages - +name: Deploy validated GitHub Pages artifact on: - push: - branches: - - master - workflow_dispatch: - + workflow_run: + workflows: [CI] + types: [completed] + branches: [master] +permissions: + contents: read + actions: read + pages: write + id-token: write +concurrency: + group: github-pages + cancel-in-progress: false jobs: - build-and-deploy: + deploy: + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'master' && github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-latest - + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install dependencies - run: npm install - - - name: Build - run: npm run build - - - name: Deploy - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GH_PAGES_DEPLOY_TOKEN }} - publish_dir: ./dist + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - uses: actions/setup-node@v4 + with: + node-version: '24' + - uses: actions/download-artifact@v4 + with: + name: validated-renderer-${{ github.event.workflow_run.head_sha }} + path: dist + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + - name: Verify exact artifact validated by CI + run: node scripts/release-artifact.mjs verify + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + - id: deployment + uses: actions/deploy-pages@v4 From 99b1c8537dc7894ab6710fc176265c97426d0347 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:13 +0200 Subject: [PATCH 08/22] A: Add monitoring config and error presentation Centralize Sentry configuration, add user-facing error formatting and update the app error boundary. --- src/components/ui/AppErrorBoundary.jsx | 5 +++-- src/lib/errorPresentation.js | 28 ++++++++++++++++++++++++++ src/lib/monitoring/config.js | 9 +++++++++ src/lib/monitoring/sentry.js | 18 ++++++++--------- 4 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 src/lib/errorPresentation.js create mode 100644 src/lib/monitoring/config.js diff --git a/src/components/ui/AppErrorBoundary.jsx b/src/components/ui/AppErrorBoundary.jsx index 69559e3..b73d9bf 100644 --- a/src/components/ui/AppErrorBoundary.jsx +++ b/src/components/ui/AppErrorBoundary.jsx @@ -1,12 +1,13 @@ +import i18n from '@/i18n'; import * as Sentry from '@sentry/react'; function FallbackScreen() { return (
-

Unexpected Error

+

{i18n.t('errors.unexpectedTitle')}

- An unexpected error occurred. Please restart the app and try again. + {i18n.t('errors.unexpectedDescription')}

diff --git a/src/lib/errorPresentation.js b/src/lib/errorPresentation.js new file mode 100644 index 0000000..b3ac28a --- /dev/null +++ b/src/lib/errorPresentation.js @@ -0,0 +1,28 @@ +import i18n from '@/i18n'; + +const groups = { + STALE_PROFILE: 'stale', STALE_PROJECT: 'stale', PROJECT_EXISTS: 'exists', PROJECT_NOT_FOUND: 'missing', + RECORD_REFERENCED: 'referenced', DEFINITION_REFERENCED: 'referenced', + CATEGORY_CYCLE: 'categoryCycle', CATEGORY_DEPTH: 'categoryDepth', CATEGORY_PARENT_MISSING: 'reference', + REFERENCE_MISSING: 'reference', AMBIGUOUS_REFERENCE: 'reference', FOREIGN_OWNER: 'reference', FOREIGN_OWNERSHIP: 'reference', + INVALID_NAME: 'name', DUPLICATE_CATEGORY_NAME: 'duplicateName', DUPLICATE_PARTICIPANT_NAME: 'duplicateName', + INVALID_DEFINITION: 'definition', INVALID_PROPERTY_NAME: 'definition', INVALID_PROPERTY_TYPE: 'definition', INVALID_PROPERTY_DEFAULT: 'definition', INCOMPATIBLE_SCHEMA: 'definition', + LEGACY_REFERENCE_REPAIR_REQUIRED: 'repair', PROJECT_REPAIR_REQUIRED: 'repair', LOCALIZATION_REPAIR_REQUIRED: 'repair', INVALID_PROJECT_DATA: 'repair', + MISSING_MEDIA: 'media', INVALID_MEDIA: 'media', MEDIA_SIZE_MISMATCH: 'media', + missing_reference: 'reference', ambiguous_reference: 'reference', duplicate_identity: 'duplicateIdentity', missing_project: 'missing', missing_dialogue: 'missing', +}; + +/** Translate presentation, retaining structured record identities and paths as evidence. */ +export function describeError(error, t = i18n.t.bind(i18n)) { + const code = String(error?.code || ''); + const group = groups[code] || (/^ARCHIVE_.*_LIMIT$/.test(code) ? 'archiveLimit' : /^INVALID_ARCHIVE/.test(code) || code === 'UNSAFE_ARCHIVE_PATH' ? 'archiveCorrupt' : /^UNSUPPORTED_ARCHIVE/.test(code) ? 'archiveUnsupported' : error?.name === 'QuotaExceededError' ? 'quota' : 'unexpected'); + const description = t(`errors.details.${group}`); + const evidence = [...(error?.references || []), ...(error?.diagnostics || []), error || {}] + .map(record => ['code', 'table', 'id', 'projectId', 'dialogueId', 'nodeId', 'rowId', 'key', 'path'].map(key => record?.[key]).filter(value => typeof value === 'string' && value).join(' · ')) + .filter(Boolean); + return evidence.length ? `${description} ${[...new Set(evidence)].join('; ')}` : description; +} + +export function errorToast(error, action = 'update') { + return { variant: 'error', title: i18n.t(`errors.actions.${action}`), description: describeError(error) }; +} diff --git a/src/lib/monitoring/config.js b/src/lib/monitoring/config.js new file mode 100644 index 0000000..f24960d --- /dev/null +++ b/src/lib/monitoring/config.js @@ -0,0 +1,9 @@ +export function parseSampleRate(value, fallback = 0.1) { + if (value == null || String(value).trim() === '') return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : fallback; +} + +export function rendererReportingEnabled(env) { + return Boolean(String(env.VITE_SENTRY_DSN || '').trim()) && (!env.DEV || String(env.VITE_SENTRY_ENABLE_IN_DEV || '') === '1'); +} diff --git a/src/lib/monitoring/sentry.js b/src/lib/monitoring/sentry.js index ea36a54..544af41 100644 --- a/src/lib/monitoring/sentry.js +++ b/src/lib/monitoring/sentry.js @@ -1,26 +1,24 @@ import * as Sentry from '@sentry/react'; +import { parseSampleRate, rendererReportingEnabled } from './config'; let sentryInitialized = false; - -function parseSampleRate(value, fallback = 0.1) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} +let reportingEnabled = false; export function initRendererSentry() { if (sentryInitialized) return false; const dsn = String(import.meta.env.VITE_SENTRY_DSN || '').trim(); if (!dsn) return false; + reportingEnabled = rendererReportingEnabled(import.meta.env); Sentry.init({ dsn, environment: String(import.meta.env.MODE || 'production'), tracesSampleRate: parseSampleRate(import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE, 0.1), - enabled: - import.meta.env.DEV - ? String(import.meta.env.VITE_SENTRY_ENABLE_IN_DEV || '') === '1' - : true, + enabled: reportingEnabled, + // Injected from the same identity written to dist/release.json. + // eslint-disable-next-line no-undef + release: __APP_RELEASE__, }); sentryInitialized = true; @@ -28,5 +26,5 @@ export function initRendererSentry() { } export function isRendererSentryEnabled() { - return sentryInitialized; + return sentryInitialized && reportingEnabled; } From 7fa3ecc967ff7535d78823f0690c3e4ead6709df Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:13 +0200 Subject: [PATCH 09/22] U: Rework localization string table Update string table materialization and handling of localized strings. --- src/lib/localization/stringTable.js | 115 ++++++++++++++++------------ 1 file changed, 66 insertions(+), 49 deletions(-) diff --git a/src/lib/localization/stringTable.js b/src/lib/localization/stringTable.js index a3698f4..173b95e 100644 --- a/src/lib/localization/stringTable.js +++ b/src/lib/localization/stringTable.js @@ -1,3 +1,5 @@ +import dialogueNodeConfig from '@/config/dialogueNodes.json'; + const DEFAULT_LOCALE = 'en'; export const LOCALIZED_STRING_FIELDS = Object.freeze({ @@ -138,7 +140,6 @@ export function normalizeProjectLocalizationConfig(rawConfig = {}) { } return { - enabled: true, defaultLocale: rawDefault, supportedLocales: supported.length > 0 ? supported : [rawDefault], }; @@ -154,6 +155,24 @@ export function ensureDialogueLocalizationSlug(dialogue = null) { return slugifyForKeySegment(dialogue?.name || dialogue?.id || 'dialogue', 'dialogue'); } +/** Allocate against the entire project inside the caller's write transaction. + * Keep existing unique namespaces immutable, including across dialogue renames. */ +export function allocateDialogueLocalizationSlug(dialogue, projectDialogues = []) { + const base = ensureDialogueLocalizationSlug(dialogue); + const used = new Set(projectDialogues.filter((other) => String(other.id) !== String(dialogue.id)).map(ensureDialogueLocalizationSlug)); + if (!used.has(base)) return base; + const identity = slugifyForKeySegment(dialogue.id, '') || shortHash(dialogue.id, 8); + const candidate = `${base}_${identity}`; + let result = candidate; + for (let index = 2; used.has(result); index += 1) result = `${candidate}_${index}`; + return result; +} + +export function getLocalizedNodeFields(node) { + const configured = (dialogueNodeConfig[node?.type]?.sections || []).flatMap((section) => section.fields || []).filter((field) => field.localizable).map((field) => field.id); + return new Set(configured); +} + export function ensureNodeLocalizationToken(node = null) { const existing = slugifyForKeySegment(node?.data?.localizationNodeToken || '', ''); if (existing && existing !== 'item') return existing; @@ -436,6 +455,15 @@ export function prepareLocalizedNodesAndEntries({ const seenKeys = new Set(); const seenNodeTokens = new Set(); const nodesForPersistence = []; + const assertEntryOwner = (entry, nodeId, rowId, field) => { + if (!entry) return; + if ((entry.projectId && entry.projectId !== safeProjectId) || (entry.dialogueId && entry.dialogueId !== safeDialogueId) || (entry.nodeId && entry.nodeId !== nodeId) || (entry.rowId && entry.rowId !== rowId) || entry.field !== field) { + const error = new Error(`Localization entry belongs to another record: ${entry.key}`); + error.code = 'LOCALIZATION_REPAIR_REQUIRED'; + error.diagnostics = [{ type: 'entry_scope_mismatch', key: entry.key, nodeId, rowId, field }]; + throw error; + } + }; for (const rawNode of nodes || []) { const node = { ...rawNode }; @@ -499,7 +527,8 @@ export function prepareLocalizedNodesAndEntries({ } seenKeys.add(key); - const existing = entriesByKey.get(key) || existingByKey.get(key) || null; + const existing = entriesByKey.get(key) || existingByKey.get(key) || existingByKey.get(rawNode?.data?.[keyField]) || null; + assertEntryOwner(existing, nodeId, rowId, field); const values = { ...(existing?.values || {}) }; const hasRawValue = Object.prototype.hasOwnProperty.call(rawNode?.data || {}, valueField); if (hasRawValue) { @@ -507,6 +536,7 @@ export function prepareLocalizedNodesAndEntries({ } entriesByKey.set(key, { + ...existing, projectId: safeProjectId, key, dialogueId: safeDialogueId, @@ -523,6 +553,7 @@ export function prepareLocalizedNodesAndEntries({ }; if ( + getLocalizedNodeFields(rawNode).has('displayName') || Object.prototype.hasOwnProperty.call(rawNode?.data || {}, 'displayName') || Object.prototype.hasOwnProperty.call(nodeData, 'displayNameKey') ) { @@ -534,6 +565,7 @@ export function prepareLocalizedNodesAndEntries({ } if ( + getLocalizedNodeFields(rawNode).has('selectionTitle') || Object.prototype.hasOwnProperty.call(rawNode?.data || {}, 'selectionTitle') || Object.prototype.hasOwnProperty.call(nodeData, 'selectionTitleKey') ) { @@ -602,7 +634,8 @@ export function prepareLocalizedNodesAndEntries({ } seenKeys.add(rowTextKey); - const existing = entriesByKey.get(rowTextKey) || existingByKey.get(rowTextKey) || null; + const existing = entriesByKey.get(rowTextKey) || existingByKey.get(rowTextKey) || existingByKey.get(rawRow?.textKey) || null; + assertEntryOwner(existing, nodeId, rowId, LOCALIZED_STRING_FIELDS.rowText); const values = { ...(existing?.values || {}) }; const rawSourceRow = Array.isArray(rawNode?.data?.dialogueRows) ? rawNode.data.dialogueRows[rowIndex] @@ -612,6 +645,7 @@ export function prepareLocalizedNodesAndEntries({ } entriesByKey.set(rowTextKey, { + ...existing, projectId: safeProjectId, key: rowTextKey, dialogueId: safeDialogueId, @@ -768,59 +802,42 @@ export function buildLocalizedEntriesFromNodes({ } export function validateLocalizedEntriesForDialogue({ - nodes = [], - entries = [], - defaultLocale = DEFAULT_LOCALE, + nodes = [], entries = [], defaultLocale = DEFAULT_LOCALE, + projectId = '', dialogueId = '', dialogueSlug = '', projectEntries = [], }) { const errors = []; - const byKey = new Map( - (entries || []) - .map((entry) => normalizeLocalizedStringEntry(entry)) - .filter((entry) => entry.key) - .map((entry) => [entry.key, entry]) - ); - const requiredKeys = []; - const seen = new Set(); - - for (const node of nodes || []) { - const nodeData = node?.data || {}; - if (nodeData.displayNameKey) requiredKeys.push(nodeData.displayNameKey); - if (nodeData.selectionTitleKey) requiredKeys.push(nodeData.selectionTitleKey); - if (Array.isArray(nodeData.dialogueRows)) { - for (const row of nodeData.dialogueRows) { - if (row?.textKey) requiredKeys.push(row.textKey); - } - } + const byKey = new Map(); + for (const raw of entries) { + const entry = normalizeLocalizedStringEntry(raw); + if (byKey.has(entry.key)) errors.push({ type: 'duplicate_entry_key', key: entry.key }); + byKey.set(entry.key, entry); } - - for (const key of requiredKeys) { - if (!key) { - errors.push({ type: 'missing_key_ref' }); - continue; - } - if (!(READABLE_KEY_REGEX.test(key) || LEGACY_ROW_KEY_REGEX.test(key) || LEGACY_NODE_KEY_REGEX.test(key))) { - errors.push({ type: 'invalid_key_format', key }); - } - if (seen.has(key)) { - errors.push({ type: 'duplicate_key_ref', key }); - } + const seen = new Set(); + const normalizedDefault = normalizeLocaleTag(defaultLocale, DEFAULT_LOCALE); + const check = (node, field, key, row = null) => { + const scope = { nodeId: String(node.id), rowId: String(row?.id || ''), field }; + if (!key) { errors.push({ type: 'missing_key_ref', ...scope }); return; } + const parsed = parseLocalizedStringKey(key); + if (!parsed) errors.push({ type: 'invalid_key_format', key, ...scope }); + if (seen.has(key)) errors.push({ type: 'duplicate_key_ref', key, ...scope }); seen.add(key); - + if (parsed && (parsed.field !== field || (parsed.keyType === 'legacy' && ((dialogueId && parsed.dialogueId !== String(dialogueId)) || parsed.nodeId !== scope.nodeId || (row && parsed.rowId !== scope.rowId))) || (parsed.keyType === 'readable' && ((dialogueSlug && parsed.dialogueSlug !== dialogueSlug) || parsed.nodeToken !== ensureNodeLocalizationToken(node) || (row && parsed.rowToken !== ensureRowLocalizationToken(row)))))) errors.push({ type: 'key_scope_mismatch', key, ...scope }); const entry = byKey.get(key); - if (!entry) { - errors.push({ type: 'missing_entry', key }); - continue; - } - const normalizedDefault = normalizeLocaleTag(defaultLocale, DEFAULT_LOCALE); - if (!Object.prototype.hasOwnProperty.call(entry.values || {}, normalizedDefault)) { - errors.push({ type: 'missing_default_locale_value', key, locale: normalizedDefault }); + if (!entry) { errors.push({ type: 'missing_entry', key, ...scope }); return; } + if ((projectId && entry.projectId !== String(projectId)) || (dialogueId && entry.dialogueId !== String(dialogueId)) || ((dialogueId || entry.nodeId) && entry.nodeId !== scope.nodeId) || ((dialogueId || entry.rowId) && entry.rowId !== scope.rowId) || entry.field !== field) errors.push({ type: 'entry_scope_mismatch', key, ...scope }); + if (projectEntries.some((other) => other.key === key && ((projectId && String(other.projectId) !== String(projectId)) || (dialogueId && String(other.dialogueId) !== String(dialogueId))))) errors.push({ type: 'project_key_collision', key, ...scope }); + if (!Object.prototype.hasOwnProperty.call(entry.values, normalizedDefault)) errors.push({ type: 'missing_default_locale_value', key, locale: normalizedDefault, ...scope }); + }; + for (const node of nodes) { + if (node.type === 'placeholderNode') continue; + const data = node.data || {}; + const fields = getLocalizedNodeFields(node); + for (const field of ['displayName', 'selectionTitle']) { + if (fields.has(field) || Object.prototype.hasOwnProperty.call(data, field) || Object.prototype.hasOwnProperty.call(data, `${field}Key`)) check(node, field, data[`${field}Key`]); } + for (const row of data.dialogueRows || []) check(node, 'rowText', row?.textKey, row); } - - return { - valid: errors.length === 0, - errors, - }; + return { valid: errors.length === 0, errors }; } export function buildStringTableV2Payload({ From 5371e573ae811c206cd62eb4f6252252805ab824 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:22 +0200 Subject: [PATCH 10/22] A: Add persistence layer and domain integrity Add canonical project, schema, migration, archive, remap and repository modules; update db, profile, storage, asset and template helpers. --- src/lib/assetNaming.js | 2 +- src/lib/audioUtils.js | 1 - src/lib/db.js | 345 ++++++++--------------- src/lib/domainIntegrity.js | 152 ++++++++++ src/lib/onboarding/templateLoader.js | 47 +-- src/lib/persistence/canonicalProject.js | 193 +++++++++++++ src/lib/persistence/migration.js | 155 ++++++++++ src/lib/persistence/projectArchive.js | 271 ++++++++++++++++++ src/lib/persistence/projectRemap.js | 76 +++++ src/lib/persistence/projectRepository.js | 133 +++++++++ src/lib/persistence/schema.js | 24 ++ src/lib/profile/activeProfile.js | 19 +- src/lib/recoveryEvidence.js | 20 ++ src/lib/storageUtils.js | 134 ++------- 14 files changed, 1211 insertions(+), 361 deletions(-) create mode 100644 src/lib/domainIntegrity.js create mode 100644 src/lib/persistence/canonicalProject.js create mode 100644 src/lib/persistence/migration.js create mode 100644 src/lib/persistence/projectArchive.js create mode 100644 src/lib/persistence/projectRemap.js create mode 100644 src/lib/persistence/projectRepository.js create mode 100644 src/lib/persistence/schema.js create mode 100644 src/lib/recoveryEvidence.js diff --git a/src/lib/assetNaming.js b/src/lib/assetNaming.js index 0ff382b..edc9ec0 100644 --- a/src/lib/assetNaming.js +++ b/src/lib/assetNaming.js @@ -23,7 +23,7 @@ export function sanitizeAudioFileName(fileName, fallbackBase = 'Audio') { const extRaw = hasExt ? raw.slice(lastDot + 1) : ''; const safeBase = sanitizeUnrealIdentifier(base, fallbackBase); - const safeExt = sanitizeUnrealIdentifier(extRaw, '').toLowerCase(); + const safeExt = hasExt ? toAscii(extRaw).replace(/[^A-Za-z0-9]/g, '').toLowerCase() : ''; return safeExt ? `${safeBase}.${safeExt}` : safeBase; } diff --git a/src/lib/audioUtils.js b/src/lib/audioUtils.js index c6b80e6..75b007c 100644 --- a/src/lib/audioUtils.js +++ b/src/lib/audioUtils.js @@ -55,6 +55,5 @@ export function restoreAudioFromStorage(storedAudio) { name: sanitizeAudioFileName(storedAudio.name, 'Audio'), size: storedAudio.size || blob.size, blob: blob, - url: URL.createObjectURL(blob), }; } diff --git a/src/lib/db.js b/src/lib/db.js index b1a031c..e91895e 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -1,234 +1,139 @@ import Dexie from 'dexie'; -import { getActiveProfileId } from '@/lib/profile/activeProfile'; +import { getActiveProfileId, getProfileGeneration, subscribeProfileChanges } from '@/lib/profile/activeProfile'; +import { DATABASE_SCHEMA, AUTHORING_TABLES } from '@/lib/persistence/schema'; +import { migrateLegacyDatabase } from '@/lib/persistence/migration'; +import { transformLegacyRecords } from '@/lib/persistence/migration'; -/** - * Mountea Dialoguer IndexedDB Database - * Manages local storage for projects, dialogues, participants, and categories - */ export class MounteaDialoguerDB extends Dexie { - constructor(databaseName = 'MounteaDialoguerDB') { - super(databaseName); - - // Version 1 - Auto-increment IDs (deprecated) - this.version(1).stores({ - projects: '++id, name, createdAt, modifiedAt', - dialogues: '++id, projectId, name, createdAt, modifiedAt', - participants: '++id, projectId, name, category', - categories: '++id, projectId, name', - decorators: '++id, projectId, name, type', - nodes: '++id, dialogueId, type, position', - edges: '++id, dialogueId, source, target', - }); - - // Version 2 - UUID-based IDs - this.version(2).stores({ - projects: 'id, name, createdAt, modifiedAt', - dialogues: 'id, projectId, name, createdAt, modifiedAt', - participants: 'id, projectId, name, category', - categories: 'id, projectId, name', - decorators: 'id, projectId, name, type', - nodes: 'id, dialogueId, type, position', - edges: 'id, dialogueId, source, target', - }); - - // Version 3 - Add parent category support (hierarchical categories) - this.version(3).stores({ - projects: 'id, name, createdAt, modifiedAt', - dialogues: 'id, projectId, name, createdAt, modifiedAt', - participants: 'id, projectId, name, category', - categories: 'id, projectId, name, parentCategoryId', - decorators: 'id, projectId, name, type', - nodes: 'id, dialogueId, type, position', - edges: 'id, dialogueId, source, target', - }); - - // Version 4 - Use compound keys for nodes and edges (scoped by dialogue) - this.version(4).stores({ - projects: 'id, name, createdAt, modifiedAt', - dialogues: 'id, projectId, name, createdAt, modifiedAt', - participants: 'id, projectId, name, category', - categories: 'id, projectId, name, parentCategoryId', - decorators: 'id, projectId, name, type', - nodes: '[dialogueId+id], dialogueId, type', - edges: '[dialogueId+id], dialogueId, source, target', - }).upgrade(async (trans) => { - // Migrate existing nodes and edges to use compound keys - // Dexie will automatically handle the re-indexing with the new compound key - // We just need to ensure the data has both dialogueId and id fields - const nodes = await trans.table('nodes').toArray(); - const edges = await trans.table('edges').toArray(); - - // Clear and re-add with compound keys - await trans.table('nodes').clear(); - await trans.table('edges').clear(); - - // Re-add nodes (they should already have dialogueId and id) - if (nodes.length > 0) { - await trans.table('nodes').bulkAdd(nodes); - } - - // Re-add edges (they should already have dialogueId and id) - if (edges.length > 0) { - await trans.table('edges').bulkAdd(edges); - } - }); - - // Version 5 - Add sync metadata tables - this.version(5).stores({ - projects: 'id, name, createdAt, modifiedAt', - dialogues: 'id, projectId, name, createdAt, modifiedAt', - participants: 'id, projectId, name, category', - categories: 'id, projectId, name, parentCategoryId', - decorators: 'id, projectId, name, type', - nodes: '[dialogueId+id], dialogueId, type', - edges: '[dialogueId+id], dialogueId, source, target', - syncAccounts: 'provider, accountId, email, expiresAt', - syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt', - }); - - // Version 6 - Add condition definitions table - this.version(6).stores({ - projects: 'id, name, createdAt, modifiedAt', - dialogues: 'id, projectId, name, createdAt, modifiedAt', - participants: 'id, projectId, name, category', - categories: 'id, projectId, name, parentCategoryId', - decorators: 'id, projectId, name, type', - conditions: 'id, projectId, name, type', - nodes: '[dialogueId+id], dialogueId, type', - edges: '[dialogueId+id], dialogueId, source, target', - syncAccounts: 'provider, accountId, email, expiresAt', - syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt', - }); - - // Version 7 - Add localized strings StringTable - this.version(7).stores({ - projects: 'id, name, createdAt, modifiedAt', - dialogues: 'id, projectId, name, createdAt, modifiedAt', - participants: 'id, projectId, name, category', - categories: 'id, projectId, name, parentCategoryId', - decorators: 'id, projectId, name, type', - conditions: 'id, projectId, name, type', - nodes: '[dialogueId+id], dialogueId, type', - edges: '[dialogueId+id], dialogueId, source, target', - syncAccounts: 'provider, accountId, email, expiresAt', - syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt', - localizedStrings: - '[projectId+key], projectId, dialogueId, nodeId, rowId, field, modifiedAt', - }); - - // Version 8 - Add sync deletions tombstone table - this.version(8).stores({ - projects: 'id, name, createdAt, modifiedAt', - dialogues: 'id, projectId, name, createdAt, modifiedAt', - participants: 'id, projectId, name, category', - categories: 'id, projectId, name, parentCategoryId', - decorators: 'id, projectId, name, type', - conditions: 'id, projectId, name, type', - nodes: '[dialogueId+id], dialogueId, type', - edges: '[dialogueId+id], dialogueId, source, target', - syncAccounts: 'provider, accountId, email, expiresAt', - syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt', - syncDeletions: '[projectId+provider], projectId, provider, deletedAt', - localizedStrings: - '[projectId+key], projectId, dialogueId, nodeId, rowId, field, modifiedAt', - }); - - // Version 9 - Add sync catalog state and tombstone ledger tables - this.version(9).stores({ - projects: 'id, name, createdAt, modifiedAt', - dialogues: 'id, projectId, name, createdAt, modifiedAt', - participants: 'id, projectId, name, category', - categories: 'id, projectId, name, parentCategoryId', - decorators: 'id, projectId, name, type', - conditions: 'id, projectId, name, type', - nodes: '[dialogueId+id], dialogueId, type', - edges: '[dialogueId+id], dialogueId, source, target', - syncAccounts: 'provider, accountId, email, expiresAt', - syncProjects: '[projectId+provider], projectId, provider, revision, remoteFileId, lastSyncedAt', - syncDeletions: '[projectId+provider], projectId, provider, deletedAt', - syncCatalogState: '[provider+profileId], provider, profileId, catalogRevision, fetchedAt, status', - syncTombstones: '[provider+entityType+entityId], provider, entityType, entityId, projectId, deletedAt, expiresAt, pending, acknowledgedAt', - localizedStrings: - '[projectId+key], projectId, dialogueId, nodeId, rowId, field, modifiedAt', - }); - this.projects = this.table('projects'); - this.dialogues = this.table('dialogues'); - this.participants = this.table('participants'); - this.categories = this.table('categories'); - this.decorators = this.table('decorators'); - this.conditions = this.table('conditions'); - this.nodes = this.table('nodes'); - this.edges = this.table('edges'); - this.syncAccounts = this.table('syncAccounts'); - this.syncProjects = this.table('syncProjects'); - this.syncDeletions = this.table('syncDeletions'); - this.syncCatalogState = this.table('syncCatalogState'); - this.syncTombstones = this.table('syncTombstones'); - this.localizedStrings = this.table('localizedStrings'); + constructor(sourceName = 'MounteaDialoguerDB', options = {}) { + super(`${sourceName}__generation2`); + this.sourceName = sourceName; + this.version(1).stores(DATABASE_SCHEMA); + for (const name of Object.keys(DATABASE_SCHEMA)) this[name] = this.table(name); + this.on('ready', () => migrateLegacyDatabase(this, sourceName, options), true); } } - -function sanitizeProfileIdForDb(rawValue) { - const value = String(rawValue || '').trim(); - if (!value) return 'local'; - return value.replace(/[^a-zA-Z0-9_-]/g, '-'); +export function getDatabaseNameForProfile(profileId = getActiveProfileId()) { + const normalized = String(profileId || 'local'); + if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) throw new Error('Invalid profile identifier; refusing an ambiguous database name.'); + return normalized === 'local' ? 'MounteaDialoguerDB' : `MounteaDialoguerDB__${normalized}`; } - -function getDatabaseNameForProfile(profileId) { - const normalizedProfileId = sanitizeProfileIdForDb(profileId); - if (normalizedProfileId === 'local') { - return 'MounteaDialoguerDB'; - } - return `MounteaDialoguerDB__${normalizedProfileId}`; +const instances = new Map(); +let activeController = new AbortController(); +subscribeProfileChanges(() => { activeController.abort(); activeController = new AbortController(); }); +function instanceFor(profileId = getActiveProfileId()) { + const name = getDatabaseNameForProfile(profileId); + if (!instances.has(name)) instances.set(name, new MounteaDialoguerDB(name)); + return instances.get(name); } - -function resolveActiveProfileId() { - try { - return sanitizeProfileIdForDb(getActiveProfileId()); - } catch (error) { - return 'local'; - } +// Legacy single-step access remains compatible. Multi-step callers capture a +// context and use its database for the entire operation, never the moving proxy. +export const db = new Proxy({}, { + get(_target, property) { const instance = instanceFor(); const value = instance[property]; return typeof value === 'function' ? value.bind(instance) : value; }, + set(_target, property, value) { instanceFor()[property] = value; return true; }, +}); +export function getProfileScopedDbName() { return instanceFor().name; } +export async function initializeRepository(profileId = getActiveProfileId()) { + const instance = instanceFor(profileId); await instance.open(); return instance; } - -let activeDbInstance = null; -let activeDbProfileId = ''; - -function ensureDbInstance() { - const profileId = resolveActiveProfileId(); - if (!activeDbInstance || activeDbProfileId !== profileId) { - if (activeDbInstance) { - try { - activeDbInstance.close(); - } catch (error) { - // Swallow close errors; switching profile should be best-effort. - } +export async function getRepositoryContext() { + const profileId = getActiveProfileId(), generation = getProfileGeneration(), signal = activeController.signal; + const instance = instanceFor(profileId); + const assertCurrent = () => { + if (signal.aborted || generation !== getProfileGeneration() || profileId !== getActiveProfileId()) { + const error = new Error('The active profile changed. Retry this operation in the current profile.'); + error.code = 'STALE_PROFILE'; throw error; } - activeDbInstance = new MounteaDialoguerDB(getDatabaseNameForProfile(profileId)); - activeDbProfileId = profileId; - } - return activeDbInstance; + }; + await instance.open(); assertCurrent(); + return Object.freeze({ db: instance, profileId, generation, signal, assertCurrent }); +} +export async function readProjectRecords(projectId, context) { + const captured = context || await getRepositoryContext(); captured.assertCurrent(); + const result = await captured.db.transaction('r', AUTHORING_TABLES.map((name) => captured.db.table(name)), async () => { + const project = await captured.db.projects.get(projectId); + const dialogues = await captured.db.dialogues.where('projectId').equals(projectId).toArray(); + const dialogueIds = dialogues.map((row) => row.id); + const records = { projects: project ? [project] : [], dialogues }; + for (const table of AUTHORING_TABLES.filter((name) => !['projects', 'dialogues'].includes(name))) records[table] = await (['nodes', 'edges'].includes(table) ? captured.db[table].where('dialogueId').anyOf(dialogueIds) : captured.db[table].where('projectId').equals(projectId)).toArray(); + return records; + }); + captured.assertCurrent(); return result; +} +export async function getRecoveryDiagnostics(projectId, context) { + const captured = context || await getRepositoryContext(); captured.assertCurrent(); + const records = await captured.db.recoveryRecords.filter((record) => record.status !== 'resolved' && (!projectId || !record.projectId || record.projectId === projectId)).toArray(); + captured.assertCurrent(); return records; +} +export async function assertProjectReady(projectId, context) { + const diagnostics = await getRecoveryDiagnostics(projectId, context); + if (diagnostics.length) { const error = new Error('Repair unresolved migration references before exporting or synchronizing this project.'); error.code = 'PROJECT_REPAIR_REQUIRED'; error.diagnostics = diagnostics; throw error; } } -export const db = new Proxy( - {}, - { - get(_target, property) { - const instance = ensureDbInstance(); - const value = instance[property]; - if (typeof value === 'function') { - return value.bind(instance); - } - return value; - }, - set(_target, property, value) { - const instance = ensureDbInstance(); - instance[property] = value; - return true; - }, - } -); +/** After repairing references through the normal authoring UI, revalidate them + * atomically. Historical diagnostics/originals remain available for recovery; + * callers cannot simply dismiss a still-invalid reference. Quarantined records + * require explicit restoration before their diagnostic can be cleared. */ +export async function revalidateProjectRecovery(projectId, context) { + const captured = context || await getRepositoryContext(); captured.assertCurrent(); + const result = await captured.db.transaction('rw', [...AUTHORING_TABLES, 'recoveryRecords'].map((name) => captured.db.table(name)), async () => { + const records = await readProjectRecords(projectId, captured); + const { diagnostics } = transformLegacyRecords(records); + const previous = await captured.db.recoveryRecords.where('projectId').equals(projectId).toArray(); + const locationKey = (record) => JSON.stringify([record.table, record.recordKey ?? (['nodes', 'edges'].includes(record.table) ? [record.original?.dialogueId, record.original?.id] : record.original?.id), record.path, record.rowId || null]); + const unresolved = new Map(diagnostics.map((record) => [locationKey(record), record])); + for (const record of previous) { + if (['duplicate_identity', 'unknown_table', 'missing_project', 'missing_dialogue'].includes(record.code)) continue; + const key = record.recordKey ?? (['nodes', 'edges'].includes(record.table) ? [record.original?.dialogueId, record.original?.id] : record.original?.id); + const candidates = records[record.table] || []; + const candidate = candidates.find((candidate) => Array.isArray(key) ? candidate.id === key[1] && candidate.dialogueId === key[0] : candidate.id === key); + if (!candidate || (record.rowId && !candidate.data?.dialogueRows?.some((row) => row.id === record.rowId))) continue; + const location = locationKey(record); + if (unresolved.has(location)) { unresolved.delete(location); continue; } + if (record.status !== 'resolved') await captured.db.recoveryRecords.update(record.id, { status: 'resolved', resolvedAt: new Date().toISOString() }); + } + if (unresolved.size) await captured.db.recoveryRecords.bulkAdd([...unresolved.values()]); + captured.assertCurrent(); return diagnostics; + }); + captured.assertCurrent(); return result; +} -export function getProfileScopedDbName() { - return ensureDbInstance().name; +export async function restoreQuarantinedNode(diagnosticId, restoredNode, context) { + const captured = context || await getRepositoryContext(); captured.assertCurrent(); + const diagnostic = await captured.db.recoveryRecords.get(diagnosticId); captured.assertCurrent(); + if (diagnostic?.code !== 'duplicate_identity' || diagnostic.table !== 'nodes' || diagnostic.status === 'resolved') throw new Error('Select an unresolved quarantined node.'); + const node = structuredClone(restoredNode); + const { mutateProject } = await import('@/lib/persistence/projectRepository'); captured.assertCurrent(); + await mutateProject(diagnostic.projectId, { context: captured, recoveryResolutions: [{ id: diagnosticId, restoredKey: [node.dialogueId, node.id] }], transform: (snapshot) => { + if (!node.id || !node.type || !node.data || !snapshot.dialogues.some((dialogue) => dialogue.id === node.dialogueId)) throw new Error('The restored node must have an identity, type, data, and a dialogue in the affected project.'); + if (['start', 'startNode'].includes(node.type)) throw new Error('The dialogue already has a Start node. Choose the intended non-Start type for the recovered node.'); + if (snapshot.nodes.some((existing) => existing.dialogueId === node.dialogueId && existing.id === node.id)) throw new Error('Choose a distinct node identity before restoring this record.'); + snapshot.nodes.push(node); + } }); + return revalidateProjectRecovery(diagnostic.projectId, captured); } +/** Explicit target selection; never guess between equal display names. */ +export async function repairIdentityReference(diagnosticId, targetId, context) { + const captured = context || await getRepositoryContext(); + const diagnostic = await captured.db.recoveryRecords.get(diagnosticId); captured.assertCurrent(); + if (!diagnostic || diagnostic.status === 'resolved' || !['missing_reference', 'ambiguous_reference'].includes(diagnostic.code)) throw new Error('Select an unresolved identity reference.'); + const { mutateProject } = await import('@/lib/persistence/projectRepository'); + const { normalizeParticipant } = await import('@/lib/domainIntegrity'); captured.assertCurrent(); + await mutateProject(diagnostic.projectId, { context: captured, transform: (snapshot) => { + if (diagnostic.table === 'participants' && diagnostic.path === 'categoryId') { + const record = snapshot.participants.find((item) => item.id === (diagnostic.recordKey || diagnostic.original.id)); + if (!record) throw new Error('The original participant is unavailable; retain the evidence for recovery.'); + Object.assign(record, normalizeParticipant({ ...record, categoryId: targetId }, snapshot, record.id)); + } else if (diagnostic.table === 'nodes' && (diagnostic.path === 'participantId' || diagnostic.rowId)) { + const key = diagnostic.recordKey || [diagnostic.original.dialogueId, diagnostic.original.id]; + const node = snapshot.nodes.find((item) => item.id === key[1] && item.dialogueId === key[0]); + const target = snapshot.participants.find((item) => item.id === targetId); + if (!node || !target) throw new Error('Choose a participant and node in this project.'); + const data = diagnostic.rowId ? node.data.dialogueRows?.find((row) => row.id === diagnostic.rowId) : node.data; + if (!data) throw new Error('The affected row is unavailable; retain the evidence for recovery.'); + data.participantId = target.id; data.participant = target.name; + } else throw new Error('Repair this reference in its authoring editor, then revalidate.'); + } }); + return revalidateProjectRecovery(diagnostic.projectId, captured); +} diff --git a/src/lib/domainIntegrity.js b/src/lib/domainIntegrity.js new file mode 100644 index 0000000..186c01b --- /dev/null +++ b/src/lib/domainIntegrity.js @@ -0,0 +1,152 @@ +export class DomainIntegrityError extends Error { + constructor(code, message, references = []) { super(message); this.name = 'DomainIntegrityError'; this.code = code; this.references = references; } +} +const fail = (code, message, references) => { throw new DomainIntegrityError(code, message, references); }; + +export function categoryAncestors(categoryId, categories) { + const byId = new Map(categories.map((record) => [record.id, record])); + const chain = [], visited = new Set(); + let id = categoryId; + while (id) { + if (visited.has(id)) fail('CATEGORY_CYCLE', 'Category hierarchy contains a cycle.'); + visited.add(id); + const record = byId.get(id); + if (!record) fail('CATEGORY_PARENT_MISSING', 'Category parent must belong to this project.'); + chain.push(record); id = record.parentCategoryId; + } + return chain; +} +export function buildCategoryPath(id, categories) { return categoryAncestors(id, categories).reverse().map((record) => record.name).join('.'); } +export function getRootCategoryId(id, categories) { return categoryAncestors(id, categories).at(-1)?.id || null; } +export function validateEntityName(name, kind) { + const value = String(name || '').trim(); + if (!/^[A-Za-z0-9]{1,16}$/.test(value)) fail('INVALID_NAME', `${kind} name must contain 1–16 letters or numbers.`); + return value; +} +export function validateCategoryTree(categories, projectId) { + const names = new Set(), roots = new Set(); + for (const category of categories) { + if (category.projectId !== projectId) fail('FOREIGN_OWNER', 'Category must belong to this project.'); + validateEntityName(category.name, 'Category'); + const ancestors = categoryAncestors(category.id, categories); + if (ancestors.length > 5) fail('CATEGORY_DEPTH', 'Category depth cannot exceed 5 levels.'); + const root = ancestors.at(-1); + const identity = `${root.id}:${category.name}`; + if (names.has(identity)) fail('DUPLICATE_CATEGORY_NAME', 'Category name must be unique within its tree.'); + names.add(identity); + if (!category.parentCategoryId) { + if (roots.has(category.name)) fail('DUPLICATE_CATEGORY_NAME', 'Root category names must be unique.'); + roots.add(category.name); + } + } +} +export function prepareCategoryImport(projectId, existing, inputs, createId = () => crypto.randomUUID()) { + const categories = existing.map((record) => ({ ...record })), created = []; + validateCategoryTree(categories, projectId); + const byPath = new Map(categories.map((record) => [buildCategoryPath(record.id, categories), record.id])); + for (const input of inputs) { + const parts = String(input.fullPath || input.name || '').split('.'); + let path = '', parentCategoryId = null; + for (const part of parts) { + const name = validateEntityName(part, 'Category'); + path = path ? `${path}.${name}` : name; + if (byPath.has(path)) { parentCategoryId = byPath.get(path); continue; } + const now = new Date().toISOString(); + const record = { id: createId(), projectId, name, parentCategoryId, createdAt: now, modifiedAt: now }; + categories.push(record); created.push(record); byPath.set(path, record.id); parentCategoryId = record.id; + } + } + validateCategoryTree(categories, projectId); + return { categories, created }; +} +export function resolveReference(records, id, legacyName, kind, { optional = false } = {}) { + if (id) { + const record = records.find((candidate) => candidate.id === id); + if (!record) fail('REFERENCE_MISSING', `${kind} ${id} does not belong to this project.`); + return record; + } + if (!legacyName && optional) return null; + const matches = records.filter((candidate) => candidate.name === legacyName); + if (matches.length !== 1) fail(matches.length ? 'AMBIGUOUS_REFERENCE' : 'REFERENCE_MISSING', `Choose a ${kind.toLowerCase()} by identity; the legacy name "${legacyName || ''}" is ${matches.length ? 'ambiguous' : 'missing'}.`); + return matches[0]; +} +export function normalizeParticipant(record, snapshot, excludeId) { + const name = validateEntityName(record.name, 'Participant'); + const category = resolveReference(snapshot.categories, record.categoryId, record.category, 'Category'); + const root = getRootCategoryId(category.id, snapshot.categories); + for (const existing of snapshot.participants) { + if (existing.id === excludeId || existing.name !== name) continue; + const existingCategory = resolveReference(snapshot.categories, existing.categoryId, existing.category, 'Category'); + if (getRootCategoryId(existingCategory.id, snapshot.categories) === root) fail('DUPLICATE_PARTICIPANT_NAME', 'Participant name must be unique within its category tree.'); + } + return { ...record, name, categoryId: category.id, category: category.name }; +} +export function validateDefinition(definition) { + if (!String(definition.name || '').trim()) fail('INVALID_DEFINITION', 'Definition name is required.'); + if (!Array.isArray(definition.properties || [])) fail('INVALID_DEFINITION', 'Definition properties must be an array.'); + const seen = new Set(); + for (const property of definition.properties || []) { + const name = String(property.name || '').trim(); + if (!name || name !== property.name || seen.has(name) || ['__proto__', 'constructor', 'prototype'].includes(name)) fail('INVALID_PROPERTY_NAME', 'Property names must be nonempty, unique, and safe object keys.'); + seen.add(name); + if (!['string', 'number', 'boolean'].includes(property.type)) fail('INVALID_PROPERTY_TYPE', `Unsupported property type: ${property.type}`); + if (property.defaultValue !== undefined && property.defaultValue !== null && (typeof property.defaultValue !== property.type || (property.type === 'number' && !Number.isFinite(property.defaultValue)))) fail('INVALID_PROPERTY_DEFAULT', `Default for ${name} must have type ${property.type}.`); + } +} +export function findEntityReferences(snapshot, kind, record) { + const refs = []; + const add = (table, item, path) => refs.push({ table, id: item.id, dialogueId: item.dialogueId, path }); + if (kind === 'categories') { + for (const child of snapshot.categories) if (child.parentCategoryId === record.id) add('categories', child, 'parentCategoryId'); + for (const participant of snapshot.participants) if (participant.categoryId === record.id || (!participant.categoryId && participant.category === record.name)) add('participants', participant, 'categoryId'); + } + for (const node of snapshot.nodes) { + if (kind === 'participants') { + if (node.data?.participantId === record.id || (!node.data?.participantId && node.data?.participant === record.name)) add('nodes', node, 'data.participantId'); + for (const row of node.data?.dialogueRows || []) if (row.participantId === record.id || (!row.participantId && row.participant === record.name)) add('nodes', node, `data.dialogueRows.${row.id}.participantId`); + } + if (kind === 'decorators') for (const instance of node.data?.decorators || []) if (instance.id === record.id || (!instance.id && instance.name === record.name)) add('nodes', node, 'data.decorators'); + if (kind === 'dialogues' && node.data?.targetDialogue === record.id && node.dialogueId !== record.id) add('nodes', node, 'data.targetDialogue'); + if (kind === 'nodes' && node.dialogueId === record.dialogueId && node.data?.targetNode === record.id && node.id !== record.id) add('nodes', node, 'data.targetNode'); + } + if (kind === 'conditions') for (const edge of snapshot.edges) for (const rule of edge.data?.conditions?.rules || []) if (rule.id === record.id || (!rule.id && rule.name === record.name)) add('edges', edge, 'data.conditions.rules'); + return refs; +} +export function assertUnreferenced(snapshot, kind, record) { + const references = findEntityReferences(snapshot, kind, record); + if (references.length) fail('RECORD_REFERENCED', `Remove or reassign ${references.length} reference(s) before deleting ${record.name || record.id}. ${references.map((ref) => `${ref.table}/${ref.id}: ${ref.path}`).join('; ')}`, references); +} +export function assertCompatibleDefinition(snapshot, kind, previous, next) { + validateDefinition(next); + const references = findEntityReferences(snapshot, kind, previous); + if (!references.length) return; + const changed = (previous.properties || []).some((property) => !next.properties?.some((candidate) => candidate.name === property.name && candidate.type === property.type)); + if (changed) fail('DEFINITION_REFERENCED', `Remove or reassign ${references.length} reference(s) before removing, renaming or changing property types. ${references.map((ref) => `${ref.table}/${ref.id}`).join(', ')}`, references); +} +export function projectIdentityLabels(snapshot, { strict = true } = {}) { + const resolve = (...args) => { try { return resolveReference(...args); } catch (error) { if (strict) throw error; return null; } }; + for (const participant of snapshot.participants) { + const category = resolve(snapshot.categories, participant.categoryId, participant.category, 'Category'); + if (category) { participant.categoryId = category.id; participant.category = category.name; } + } + for (const node of snapshot.nodes) { + for (const data of [node.data, ...(node.data?.dialogueRows || [])].filter(Boolean)) { + const participant = resolve(snapshot.participants, data.participantId, data.participant, 'Participant', { optional: true }); + if (participant) { data.participantId = participant.id; data.participant = participant.name; } + } + for (const instance of node.data?.decorators || []) { + const definition = resolve(snapshot.decorators, instance.id, instance.name, 'Decorator'); + if (definition) { instance.id = definition.id; instance.name = definition.name; } + } + } + for (const edge of snapshot.edges) for (const rule of edge.data?.conditions?.rules || []) { + const definition = resolve(snapshot.conditions, rule.id, rule.name, 'Condition'); + if (definition) { rule.id = definition.id; rule.name = definition.name; } + } + return snapshot; +} + +export function assertGraphNodesRemovable(nodes, removedIds) { + const references = nodes.filter((node) => !removedIds.has(node.id) && removedIds.has(node.data?.targetNode)).map((node) => ({ table: 'nodes', id: node.id, path: 'data.targetNode' })); + if (references.length) fail('RECORD_REFERENCED', `Reassign return targets before deleting: ${references.map((ref) => ref.id).join(', ')}`, references); +} diff --git a/src/lib/onboarding/templateLoader.js b/src/lib/onboarding/templateLoader.js index bff3a0c..ad2a263 100644 --- a/src/lib/onboarding/templateLoader.js +++ b/src/lib/onboarding/templateLoader.js @@ -1,6 +1,5 @@ -import { isDesktopElectronRuntime } from '@/lib/electronRuntime'; +const bundledTemplateUrl = `${import.meta.env.BASE_URL}onboarding-example.mnteadlgproj`; import { - getOnboardingExampleBundledPath, getOnboardingExampleProjectRemoteUrl, } from '@/lib/runtimeConfig'; @@ -26,11 +25,28 @@ function toTemplateFile(blob, source) { } async function fetchBlob(url, sourceLabel) { - const response = await fetch(url, { cache: 'no-store' }); - if (!response.ok) { - throw new Error(`${sourceLabel} fetch failed with HTTP ${response.status}`); - } - return await response.blob(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(url, { cache: 'no-store', signal: controller.signal }); + if (!response.ok) throw new Error(`${sourceLabel} fetch failed with HTTP ${response.status}`); + const limit = 32 * 1024 * 1024; + if (Number(response.headers.get('content-length')) > limit) { controller.abort(); throw new Error('Onboarding template size is invalid'); } + const reader = response.body?.getReader(); + if (!reader) throw new Error('Onboarding template response has no body'); + const chunks = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { await reader.cancel(); controller.abort(); throw new Error('Onboarding template size is invalid'); } + chunks.push(value); + } + const blob = new Blob(chunks); + if (!blob.size) throw new Error('Onboarding template size is invalid'); + return blob; + } finally { clearTimeout(timeout); } } function tryBuildGithubRawUrl(urlString) { @@ -83,12 +99,6 @@ function buildRemoteTemplateCandidates(remoteUrl) { export async function resolveOnboardingExampleTemplateFile() { const remoteUrl = String(getOnboardingExampleProjectRemoteUrl() || '').trim(); - if (!remoteUrl) { - throw new OnboardingTemplateError( - 'Onboarding template URL is not configured', - ONBOARDING_TEMPLATE_ERROR_CODES.REMOTE_FETCH_FAILED - ); - } try { const remoteCandidates = buildRemoteTemplateCandidates(remoteUrl); @@ -117,16 +127,7 @@ export async function resolveOnboardingExampleTemplateFile() { remoteUrl: resolvedRemoteUrl, }; } catch (remoteError) { - if (!isDesktopElectronRuntime()) { - throw new OnboardingTemplateError( - 'Could not download onboarding example. Import a local .mnteadlgproj file to continue.', - ONBOARDING_TEMPLATE_ERROR_CODES.WEB_FALLBACK_REQUIRED, - { remoteUrl, cause: remoteError } - ); - } - - const bundledRelativePath = String(getOnboardingExampleBundledPath() || '').trim(); - const bundledUrl = `${import.meta.env.BASE_URL}${bundledRelativePath}`; + const bundledUrl = bundledTemplateUrl; try { const blob = await fetchBlob(bundledUrl, 'Bundled onboarding template'); return { diff --git a/src/lib/persistence/canonicalProject.js b/src/lib/persistence/canonicalProject.js new file mode 100644 index 0000000..1f8ad61 --- /dev/null +++ b/src/lib/persistence/canonicalProject.js @@ -0,0 +1,193 @@ +import { AUTHORING_TABLES } from './schema'; +import { normalizeProjectLocalizationConfig, validateLocalizedEntriesForDialogue } from '@/lib/localization/stringTable'; +import { validateCategoryTree, normalizeParticipant, validateDefinition } from '@/lib/domainIntegrity'; + +export const PROJECT_FORMAT = 'mountea.project.v3'; +export const RECORD_TABLES = AUTHORING_TABLES.filter((table) => table !== 'projects'); +export const START_NODE_ID = '00000000-0000-0000-0000-000000000001'; +export const compoundIdentity = (dialogueId, id) => JSON.stringify([dialogueId, id]); +const LOCAL_FIELDS = ['lastExportPath', 'syncTimestamp', 'syncRevision', 'lastSyncedAt', 'remoteFileId']; +export class ProjectDataError extends Error { + constructor(code, message, diagnostics = []) { super(message); this.name = 'ProjectDataError'; this.code = code; this.diagnostics = diagnostics; } +} +export function recordsToSnapshot(records) { return { version: 3, format: PROJECT_FORMAT, project: records.projects?.[0], ...Object.fromEntries(RECORD_TABLES.map((table) => [table, records[table] || []])) }; } +export function snapshotToRecords(snapshot) { return { projects: snapshot.project ? [snapshot.project] : [], ...Object.fromEntries(RECORD_TABLES.map((table) => [table, snapshot[table] || []])) }; } + +function bytesToBase64(bytes) { + let value = ''; + for (let offset = 0; offset < bytes.length; offset += 32768) value += String.fromCharCode(...bytes.subarray(offset, offset + 32768)); + return btoa(value); +} +export function decodeMediaBase64(value, path = 'audioFile') { + if (typeof value !== 'string' || !/^data:[^,]*;base64,[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new ProjectDataError('INVALID_MEDIA', `Invalid base64 media at ${path}.`, [{ path }]); + try { return Uint8Array.from(atob(value.slice(value.indexOf(',') + 1)), (character) => character.charCodeAt(0)); } + catch { throw new ProjectDataError('INVALID_MEDIA', `Invalid base64 media at ${path}.`, [{ path }]); } +} +export async function serializeAudio(audio, path) { + if (!audio) return audio; + const result = { ...audio }; + let bytes; + if (audio.blob instanceof Blob) bytes = new Uint8Array(await audio.blob.arrayBuffer()); + else if (audio.base64 || audio.dataUrl) bytes = decodeMediaBase64(audio.base64 || audio.dataUrl, path); + else throw new ProjectDataError('MISSING_MEDIA', `Audio bytes are missing at ${path}. Restore the original audio before exporting or synchronizing.`, [{ path }]); + if (Number.isFinite(audio.size) && audio.size !== bytes.length) throw new ProjectDataError('MEDIA_SIZE_MISMATCH', `Audio byte length differs from its metadata at ${path}.`, [{ path, expected: audio.size, actual: bytes.length }]); + const mimeType = audio.mimeType || audio.blob?.type || audio.type || 'application/octet-stream'; + delete result.blob; delete result.url; delete result.dataUrl; + return { ...result, mimeType, size: bytes.length, base64: `data:${mimeType};base64,${bytesToBase64(bytes)}` }; +} +function jsonValue(value, path = '', ancestors = new Set()) { + if (value === undefined || typeof value === 'function') return undefined; + if (value === null || ['string', 'boolean'].includes(typeof value)) return value; + if (typeof value === 'number') { if (Number.isFinite(value)) return value; throw new ProjectDataError('INVALID_JSON_VALUE', `Non-finite number at ${path}.`); } + if (value instanceof Date) return value.toISOString(); + if (value instanceof Blob || typeof value !== 'object') throw new ProjectDataError('INVALID_JSON_VALUE', `Unsupported authored value at ${path}.`); + if (ancestors.has(value)) throw new ProjectDataError('INVALID_JSON_VALUE', `Cyclic authored value at ${path}.`); + ancestors.add(value); + const result = Array.isArray(value) ? value.map((entry, index) => jsonValue(entry, `${path}[${index}]`, ancestors) ?? null) : Object.fromEntries(Object.keys(value).sort().flatMap((key) => { const entry = jsonValue(value[key], `${path}.${key}`, ancestors); return entry === undefined ? [] : [[key, entry]]; })); + ancestors.delete(value); return result; +} + +/** Produces durable JSON; object URLs and React Flow runtime callbacks never + * enter backups/revision hashes. Unknown JSON-compatible authored fields remain. */ +export async function canonicalizeProject(input, { allowIncompleteMedia = false } = {}) { + if (!input?.project) throw new ProjectDataError('INVALID_PROJECT', 'A project record is required.'); + const objectAt = (value, path) => { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ProjectDataError('INVALID_RECORD', `Expected an object at ${path}.`, [{ path }]); }; + const recordsAt = (value, path) => { if (value !== undefined && !Array.isArray(value)) throw new ProjectDataError('INVALID_TABLE', `Expected an array at ${path}.`, [{ path }]); for (const [index, row] of (value || []).entries()) objectAt(row, `${path}[${index}]`); }; + objectAt(input.project, 'project'); + for (const table of RECORD_TABLES) recordsAt(input[table], table); + for (const [index, node] of (input.nodes || []).entries()) { + if (node.data !== undefined) objectAt(node.data, `nodes[${index}].data`); + recordsAt(node.data?.dialogueRows, `nodes[${index}].data.dialogueRows`); + recordsAt(node.data?.decorators, `nodes[${index}].data.decorators`); + } + for (const [index, edge] of (input.edges || []).entries()) { + if (edge.data !== undefined) objectAt(edge.data, `edges[${index}].data`); + if (edge.data?.conditions !== undefined) objectAt(edge.data.conditions, `edges[${index}].data.conditions`); + recordsAt(edge.data?.conditions?.rules, `edges[${index}].data.conditions.rules`); + } + // Domain envelope is explicit so credentials/provider tables cannot leak when + // a caller accidentally supplies a database dump instead of project records. + const snapshot = { version: 3, format: PROJECT_FORMAT, project: { ...input.project } }; + for (const field of LOCAL_FIELDS) delete snapshot.project[field]; + snapshot.project.localization = normalizeProjectLocalizationConfig(snapshot.project.localization); + for (const table of RECORD_TABLES) { + if (input[table] !== undefined && !Array.isArray(input[table])) throw new ProjectDataError('INVALID_TABLE', `${table} must be an array.`, [{ path: table }]); + snapshot[table] = (input[table] || []).map((record) => ({ ...record })); + } + snapshot.dialogues = snapshot.dialogues.map((row) => { const next = { ...row }; for (const field of LOCAL_FIELDS) delete next[field]; return next; }); + snapshot.participants = await Promise.all(snapshot.participants.map(async (participant) => { + if (!participant.thumbnail) return participant; + const path = `participants.${participant.id}.thumbnail`, thumbnail = participant.thumbnail; + try { + if (typeof thumbnail !== 'object' || (!thumbnail.blob && !thumbnail.base64 && !thumbnail.dataUrl)) throw new ProjectDataError('MISSING_MEDIA', `Thumbnail bytes are missing at ${path}. Restore the original image before exporting or synchronizing.`, [{ path }]); + const normalized = await serializeAudio({ ...thumbnail, size: thumbnail.size ?? thumbnail.sizeBytes }, path); + return { ...participant, thumbnail: normalized }; + } catch (error) { + if (!allowIncompleteMedia || error.code !== 'MISSING_MEDIA') throw error; + return participant; + } + })); + snapshot.nodes = await Promise.all(snapshot.nodes.filter((node) => node.type !== 'placeholderNode').map(async (node) => { + const data = { ...(node.data || {}) }; + if (Array.isArray(data.dialogueRows)) data.dialogueRows = await Promise.all(data.dialogueRows.map(async (row, index) => { + if (!row.audioFile) return { ...row }; + try { return { ...row, audioFile: await serializeAudio(row.audioFile, `nodes.${node.id}.data.dialogueRows.${index}.audioFile`) }; } + catch (error) { + if (!allowIncompleteMedia || error.code !== 'MISSING_MEDIA') throw error; + const original = { ...row.audioFile }; delete original.url; + return { ...row, audioFile: original }; + } + })); + const result = { ...node, data }; + for (const field of ['selected', 'dragging', 'resizing', 'measured', 'positionAbsolute', 'internals']) delete result[field]; + return result; + })); + const originalPlaceholderIds = new Set((input.nodes || []).filter((node) => node.type === 'placeholderNode').map((node) => compoundIdentity(node.dialogueId, node.id))); + snapshot.edges = snapshot.edges.filter((edge) => !originalPlaceholderIds.has(compoundIdentity(edge.dialogueId, edge.source)) && !originalPlaceholderIds.has(compoundIdentity(edge.dialogueId, edge.target))).map((edge) => { const result = { ...edge }; delete result.selected; return result; }); + // IndexedDB returns primary-key order, whereas imported arrays may use any + // order. Canonicalize record sets while preserving authored embedded order. + for (const table of RECORD_TABLES) { + const key = (row) => JSON.stringify(['nodes', 'edges'].includes(table) ? [row.dialogueId, row.id] : [table === 'localizedStrings' ? row.key : row.id]); + snapshot[table].sort((left, right) => key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0); + } + return jsonValue(snapshot); +} + +export function validateCanonicalProject(snapshot, { localization = true } = {}) { + const diagnostics = []; + const issue = (code, path, message) => diagnostics.push({ code, path, message }); + const projectId = snapshot.project?.id; + if (typeof projectId !== 'string' || !projectId) issue('INVALID_ID', 'project.id', 'Project identity must be a nonempty string.'); + const maps = {}; + const domainCheck = (path, check) => { try { check(); } catch (error) { issue(error.code || 'INVALID_DOMAIN_RECORD', path, error.message); } }; + domainCheck('categories', () => validateCategoryTree(snapshot.categories || [], projectId)); + for (const row of snapshot.participants || []) domainCheck(`participants.${row.id}`, () => normalizeParticipant(row, snapshot, row.id)); + for (const table of ['decorators', 'conditions']) for (const row of snapshot[table] || []) domainCheck(`${table}.${row.id}`, () => validateDefinition(row)); + for (const table of RECORD_TABLES) { + maps[table] = new Map(); + for (const [index, row] of (snapshot[table] || []).entries()) { + const id = table === 'localizedStrings' ? row.key : row.id; + const path = `${table}[${index}]`; + if (typeof id !== 'string' || !id) issue('INVALID_ID', path, 'Record identity must be a nonempty string.'); + const key = ['nodes', 'edges'].includes(table) ? compoundIdentity(row.dialogueId, id) : id; + if (maps[table].has(key)) issue('DUPLICATE_ID', path, 'Duplicate record identity.'); + maps[table].set(key, row); + if (!['nodes', 'edges'].includes(table) && row.projectId !== projectId) issue('FOREIGN_OWNERSHIP', `${path}.projectId`, 'Record belongs to a different project.'); + } + } + for (const node of snapshot.nodes || []) { + const path = `nodes.${node.dialogueId}.${node.id}`, data = node.data || {}; + if (!maps.dialogues.has(node.dialogueId)) issue('MISSING_DIALOGUE', `${path}.dialogueId`, 'Owning dialogue is missing.'); + if (data.participantId && !maps.participants.has(data.participantId)) issue('MISSING_PARTICIPANT', `${path}.data.participantId`, 'Participant is missing.'); + if (data.participant && !data.participantId) issue('UNRESOLVED_PARTICIPANT', `${path}.data.participantId`, 'Resolve the participant name to a stable identity.'); + if (data.targetNode && !maps.nodes.has(compoundIdentity(node.dialogueId, data.targetNode))) issue('MISSING_TARGET', `${path}.data.targetNode`, 'Return target is missing.'); + if (data.targetDialogue && !maps.dialogues.has(data.targetDialogue)) issue('MISSING_TARGET', `${path}.data.targetDialogue`, 'Child dialogue is missing.'); + for (const [index, instance] of (data.decorators || []).entries()) if (!maps.decorators.has(instance.id)) issue('MISSING_DEFINITION', `${path}.data.decorators[${index}]`, 'Decorator definition is missing.'); + const rows = new Set(); + for (const [index, row] of (data.dialogueRows || []).entries()) { + if (!row.id || rows.has(row.id)) issue('INVALID_ROW_ID', `${path}.data.dialogueRows[${index}].id`, 'Row identities must be unique within a node.'); + rows.add(row.id); + if (row.participantId && !maps.participants.has(row.participantId)) issue('MISSING_PARTICIPANT', `${path}.data.dialogueRows[${index}].participantId`, 'Row participant is missing.'); + if (row.participant && !row.participantId) issue('UNRESOLVED_PARTICIPANT', `${path}.data.dialogueRows[${index}].participantId`, 'Resolve the row participant name to a stable identity.'); + } + } + for (const edge of snapshot.edges || []) { + for (const field of ['source', 'target']) if (!maps.nodes.has(compoundIdentity(edge.dialogueId, edge[field]))) issue('MISSING_ENDPOINT', `edges.${edge.id}.${field}`, 'Edge endpoint is missing.'); + for (const [index, instance] of (edge.data?.conditions?.rules || []).entries()) if (!maps.conditions.has(instance.id)) issue('MISSING_DEFINITION', `edges.${edge.id}.data.conditions.rules[${index}]`, 'Condition definition is missing.'); + } + for (const participant of snapshot.participants || []) { + if (participant.categoryId && !maps.categories.has(participant.categoryId)) issue('MISSING_CATEGORY', `participants.${participant.id}.categoryId`, 'Category is missing.'); + if (participant.category && !participant.categoryId) issue('UNRESOLVED_CATEGORY', `participants.${participant.id}.categoryId`, 'Resolve the category name to a stable identity.'); + } + for (const category of snapshot.categories || []) { + let cursor = category; const seen = new Set(); + while (cursor) { + if (seen.has(cursor.id)) { issue('CATEGORY_CYCLE', `categories.${category.id}.parentCategoryId`, 'Category hierarchy contains a cycle.'); break; } + seen.add(cursor.id); + if (seen.size > 5) { issue('CATEGORY_DEPTH', `categories.${category.id}.parentCategoryId`, 'Category depth exceeds five.'); break; } + if (cursor.parentCategoryId && !maps.categories.has(cursor.parentCategoryId)) issue('MISSING_CATEGORY', `categories.${category.id}.parentCategoryId`, 'Parent category is missing.'); + cursor = maps.categories.get(cursor.parentCategoryId); + } + } + for (const dialogue of snapshot.dialogues || []) { + const nodes = (snapshot.nodes || []).filter((node) => node.dialogueId === dialogue.id); + const start = nodes.filter((node) => ['start', 'startNode'].includes(node.type)); + if (start.length !== 1 || start[0]?.id !== START_NODE_ID) issue('INVALID_START', `dialogues.${dialogue.id}`, 'Dialogue must contain exactly one canonical Start node.'); + if (localization) { + const entries = (snapshot.localizedStrings || []).filter((entry) => entry.dialogueId === dialogue.id); + const validation = validateLocalizedEntriesForDialogue({ projectId, dialogueId: dialogue.id, dialogueSlug: dialogue.localizationSlug, nodes, entries, projectEntries: snapshot.localizedStrings, defaultLocale: snapshot.project?.localization?.defaultLocale || 'en' }); + for (const error of validation.errors) issue('LOCALIZATION_REPAIR_REQUIRED', `dialogues.${dialogue.id}.${error.key || error.nodeId || ''}.${error.field || ''}`, error.type); + } + } + return diagnostics; +} +export function requireValidProject(snapshot, options) { + const diagnostics = validateCanonicalProject(snapshot, options); + if (diagnostics.length) throw new ProjectDataError('INVALID_PROJECT_DATA', diagnostics[0].message, diagnostics); + return snapshot; +} +export const stableJson = (value) => JSON.stringify(jsonValue(value)); +export async function hashProject(snapshot) { + const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stableJson(snapshot))); + return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/lib/persistence/migration.js b/src/lib/persistence/migration.js new file mode 100644 index 0000000..1b452e2 --- /dev/null +++ b/src/lib/persistence/migration.js @@ -0,0 +1,155 @@ +import Dexie from 'dexie'; +import { DATABASE_SCHEMA } from './schema'; + +const present = (value) => value !== null && value !== undefined && value !== ''; +const identity = (value) => `${typeof value}:${String(value)}`; +const nodeKey = (dialogueId, id) => `${identity(dialogueId)}|${identity(id)}`; +const START_ID = '00000000-0000-0000-0000-000000000001'; + +/** Pure two-pass conversion. The source database remains an untouched backup. */ +export function transformLegacyRecords(source) { + const output = Object.fromEntries(Object.keys(DATABASE_SCHEMA).map((name) => [name, []])); + const maps = {}, diagnostics = []; + const originals = new WeakMap(); + const diagnose = (table, row, code, path, message, projectId = row.projectId, location = {}) => { + diagnostics.push({ id: crypto.randomUUID(), projectId, table, code, path, message, status: 'unresolved', original: structuredClone(originals.get(row) || row), recordKey: ['nodes', 'edges'].includes(table) ? [row.dialogueId, row.id] : row.id, ...location }); + }; + for (const table of ['projects', 'dialogues', 'categories', 'participants', 'decorators', 'conditions']) { + maps[table] = new Map((source[table] || []).map((row) => [identity(row.id), typeof row.id === 'number' ? crypto.randomUUID() : row.id])); + } + const remap = (table, id) => maps[table]?.get(identity(id)) ?? (typeof id === 'string' && /^\d+$/.test(id) ? maps[table]?.get(identity(Number(id))) : undefined) ?? id; + for (const [table, rows] of Object.entries(source)) { + if (!output[table]) { + for (const row of rows) diagnose(table, row, 'unknown_table', '', 'This unrecognized table requires recovery using the preserved source database.'); + continue; + } + if (['nodes', 'edges', 'migrationState', 'recoveryRecords'].includes(table)) continue; + output[table] = rows.map((original) => { + const row = structuredClone(original); + originals.set(row, original); + if (maps[table]) row.id = remap(table, row.id); + if (present(row.projectId)) row.projectId = remap('projects', row.projectId); + if (present(row.dialogueId)) row.dialogueId = remap('dialogues', row.dialogueId); + if (present(row.parentCategoryId)) row.parentCategoryId = remap('categories', row.parentCategoryId); + if (present(row.categoryId)) row.categoryId = remap('categories', row.categoryId); + if (table === 'syncTombstones') row.entityId = remap(row.entityType === 'project' ? 'projects' : 'dialogues', row.entityId); + return row; + }); + } + const dialogueProjects = new Map(output.dialogues.map((row) => [row.id, row.projectId])); + const categoryPath = (category) => { + const names = [], visited = new Set(); + let cursor = category; + while (cursor) { + if (visited.has(cursor.id)) return null; + visited.add(cursor.id); names.unshift(cursor.name); + cursor = output.categories.find((row) => row.id === cursor.parentCategoryId && row.projectId === category.projectId); + } + return names.join('.'); + }; + const resolveNamedReference = (table, row, field, projection, candidates, projectId, original = row, location = {}) => { + const path = location.rowId ? `data.dialogueRows.${location.rowId}.${field}` : field; + if (present(row[field])) { + if (!candidates.some((candidate) => candidate.id === row[field])) diagnose(table, original, 'missing_reference', path, `Referenced ${field} is missing or belongs to another project.`, projectId, location); + return; + } + if (!present(projection)) return; + const matches = candidates.filter((candidate) => candidate.name === projection || (table === 'participants' && categoryPath(candidate) === projection)); + if (matches.length === 1) row[field] = matches[0].id; + else diagnose(table, original, matches.length ? 'ambiguous_reference' : 'missing_reference', path, `Choose a replacement for ${field}: ${String(projection)}.`, projectId, location); + }; + for (const [index, row] of output.participants.entries()) resolveNamedReference('participants', row, 'categoryId', row.categoryPath || row.category, output.categories.filter((item) => item.projectId === row.projectId), row.projectId, source.participants[index], { recordKey: row.id }); + const nodeIds = new Map(), rowIds = new Map(); + for (const row of source.nodes || []) { + nodeIds.set(nodeKey(row.dialogueId, row.id), ['start', 'startNode'].includes(row.type) ? START_ID : typeof row.id === 'number' ? crypto.randomUUID() : row.id); + for (const item of row.data?.dialogueRows || []) rowIds.set(`${nodeKey(row.dialogueId, row.id)}|${identity(item.id)}`, typeof item.id === 'number' ? crypto.randomUUID() : item.id); + } + const nodeId = (dialogueId, id) => nodeIds.get(nodeKey(dialogueId, id)) ?? (typeof id === 'string' && /^\d+$/.test(id) ? nodeIds.get(nodeKey(dialogueId, Number(id))) : undefined) ?? id; + for (const original of source.nodes || []) { + const row = structuredClone(original); + originals.set(row, original); + row.id = nodeIds.get(nodeKey(original.dialogueId, original.id)); + row.dialogueId = remap('dialogues', original.dialogueId); + const projectId = dialogueProjects.get(row.dialogueId); + if (output.nodes.some((item) => item.id === row.id && item.dialogueId === row.dialogueId)) { + diagnose('nodes', original, 'duplicate_identity', 'id', 'Two records map to the same node identity. This original is quarantined for manual recovery; restore it with a distinct identity.', projectId); + continue; + } + row.data ||= {}; + if (present(row.data.targetNode)) row.data.targetNode = nodeId(original.dialogueId, row.data.targetNode); + if (present(row.data.targetDialogue)) row.data.targetDialogue = remap('dialogues', row.data.targetDialogue); + if (present(row.data.participantId)) row.data.participantId = remap('participants', row.data.participantId); + resolveNamedReference('nodes', row.data, 'participantId', row.data.participant, output.participants.filter((item) => item.projectId === projectId), projectId, original, { recordKey: [row.dialogueId, row.id], dialogueId: row.dialogueId, nodeId: row.id }); + for (const decorator of row.data.decorators || []) decorator.id = remap('decorators', decorator.id); + for (const dialogueRow of row.data.dialogueRows || []) { + dialogueRow.id = rowIds.get(`${nodeKey(original.dialogueId, original.id)}|${identity(dialogueRow.id)}`) ?? dialogueRow.id; + if (present(dialogueRow.participantId)) dialogueRow.participantId = remap('participants', dialogueRow.participantId); + resolveNamedReference('nodes', dialogueRow, 'participantId', dialogueRow.participant, output.participants.filter((item) => item.projectId === projectId), projectId, original, { recordKey: [row.dialogueId, row.id], dialogueId: row.dialogueId, nodeId: row.id, rowId: dialogueRow.id }); + } + output.nodes.push(row); + } + for (const original of source.edges || []) { + const row = structuredClone(original); + originals.set(row, original); + row.id = typeof row.id === 'number' ? crypto.randomUUID() : row.id; + row.dialogueId = remap('dialogues', original.dialogueId); + row.source = nodeId(original.dialogueId, original.source); + row.target = nodeId(original.dialogueId, original.target); + for (const rule of row.data?.conditions?.rules || []) rule.id = remap('conditions', rule.id); + output.edges.push(row); + } + for (let index = 0; index < output.localizedStrings.length; index++) { + const original = source.localizedStrings[index]; + output.localizedStrings[index].nodeId = nodeId(original.dialogueId, original.nodeId); + if (present(original.rowId)) output.localizedStrings[index].rowId = rowIds.get(`${nodeKey(original.dialogueId, original.nodeId)}|${identity(original.rowId)}`) ?? original.rowId; + } + const projects = new Set(output.projects.map((row) => row.id)); + for (const table of ['dialogues', 'categories', 'participants', 'decorators', 'conditions']) for (const row of output[table]) if (!projects.has(row.projectId)) diagnose(table, row, 'missing_project', 'projectId', 'The owning project is missing.'); + for (const row of output.categories) { + if (!categoryPath(row)) diagnose('categories', row, 'category_cycle', 'parentCategoryId', 'Remove the cycle in the category hierarchy.'); + else if (categoryPath(row).split('.').length > 5) diagnose('categories', row, 'category_depth', 'parentCategoryId', 'Move this category within the maximum depth of five.'); + if (present(row.parentCategoryId) && !output.categories.some((item) => item.id === row.parentCategoryId && item.projectId === row.projectId)) diagnose('categories', row, 'missing_reference', 'parentCategoryId', 'The parent category is missing or belongs to another project.'); + } + for (const row of output.nodes) { + const projectId = dialogueProjects.get(row.dialogueId); + if (!dialogueProjects.has(row.dialogueId)) diagnose('nodes', row, 'missing_dialogue', 'dialogueId', 'The owning dialogue is missing.'); + if (present(row.data.targetNode) && !output.nodes.some((item) => item.id === row.data.targetNode && item.dialogueId === row.dialogueId)) diagnose('nodes', row, 'missing_reference', 'data.targetNode', 'The return target is missing.', projectId); + if (present(row.data.targetDialogue) && dialogueProjects.get(row.data.targetDialogue) !== projectId) diagnose('nodes', row, 'missing_reference', 'data.targetDialogue', 'The child dialogue is missing or belongs to another project.', projectId); + for (const decorator of row.data.decorators || []) if (!output.decorators.some((item) => item.id === decorator.id && item.projectId === projectId)) diagnose('nodes', row, 'missing_reference', 'data.decorators', 'A decorator definition is missing or belongs to another project.', projectId); + } + for (const row of output.edges) for (const rule of row.data?.conditions?.rules || []) if (!output.conditions.some((item) => item.id === rule.id && item.projectId === dialogueProjects.get(row.dialogueId))) diagnose('edges', row, 'missing_reference', 'data.conditions.rules', 'A condition definition is missing or belongs to another project.', dialogueProjects.get(row.dialogueId)); + for (const row of output.edges) for (const field of ['source', 'target']) if (!output.nodes.some((node) => node.dialogueId === row.dialogueId && node.id === row[field])) diagnose('edges', row, 'missing_reference', field, 'The edge endpoint is missing.', dialogueProjects.get(row.dialogueId)); + output.recoveryRecords = diagnostics; + return { records: output, diagnostics }; +} + +export async function migrateLegacyDatabase(target, sourceName, { beforeCommit, beforeActivation } = {}) { + const manifestKey = `mountea-database-manifest::${sourceName}`; + let completed = await target.migrationState.get('generation2'); + if (!completed) { + let sourceRecords = {}, sourceVersion = null; + if (await Dexie.exists(sourceName)) { + const source = new Dexie(sourceName); // Dynamic schema: no version declaration or upgrade. + try { + await source.open(); sourceVersion = source.verno; + if (sourceVersion > 9) throw new Error(`Unsupported source database version ${sourceVersion}; use a newer application. The source has not been modified.`); + sourceRecords = await source.transaction('r', source.tables, async () => Object.fromEntries(await Promise.all(source.tables.map(async (table) => [table.name, await table.toArray()])))); + } finally { source.close(); } + } + const { records, diagnostics } = transformLegacyRecords(sourceRecords); + await target.transaction('rw', target.tables, async () => { + completed = await target.migrationState.get('generation2'); + if (completed) return; + for (const [table, rows] of Object.entries(records)) if (rows.length) await target.table(table).bulkAdd(rows); + await beforeCommit?.({ records, diagnostics }); + completed = { key: 'generation2', status: 'complete', sourceName, sourceVersion, targetName: target.name, diagnostics: diagnostics.length, completedAt: new Date().toISOString() }; + await target.migrationState.put(completed); + }); + } + await beforeActivation?.(completed); + // Activation is retried after interruption. The transactional target marker + // prevents copying again and overwriting newer edits. The manifest alone is + // never trusted as evidence that copying completed. + globalThis.localStorage?.setItem(manifestKey, JSON.stringify(completed)); + return completed; +} diff --git a/src/lib/persistence/projectArchive.js b/src/lib/persistence/projectArchive.js new file mode 100644 index 0000000..0217732 --- /dev/null +++ b/src/lib/persistence/projectArchive.js @@ -0,0 +1,271 @@ +import JSZip from 'jszip'; +import { canonicalizeProject, requireValidProject, RECORD_TABLES, decodeMediaBase64, serializeAudio, ProjectDataError } from './canonicalProject'; +import { remapProjectIdentities } from './projectRemap'; +import { getRepositoryContext } from '@/lib/db'; +import { readProjectState, prepareProjectCommit } from './projectRepository'; +import { parseImportedStringTableData } from '@/lib/localization/stringTable'; + +export const ARCHIVE_LIMITS = Object.freeze({ compressed: 25 * 1024 * 1024, json: 5 * 1024 * 1024, entries: 1000, dialogues: 250, expanded: 128 * 1024 * 1024 }); +const fail = (code, message, path) => { throw new ProjectDataError(code, message, path ? [{ path }] : []); }; +const decoder = new TextDecoder('utf-8', { fatal: true }); +const encode = (value) => new TextEncoder().encode(JSON.stringify(value)); +const crcTable = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); + return value >>> 0; +}); +function safePath(name) { + if (typeof name !== 'string' || !name || name.includes('\\') || name.startsWith('/') || name.includes(':') || name.includes('//') || [...name].some((character) => character.charCodeAt(0) < 32) || name.split('/').some((part) => part === '..' || part === '.')) fail('UNSAFE_ARCHIVE_PATH', 'Archive contains an unsafe original entry path.', name); + return name.normalize('NFC'); +} +/** Inspect original central directory names before JSZip can normalize or replace + * duplicate entries. ZIP64 and multipart containers are intentionally unsupported. */ +function inspectDirectory(bytes, budget) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let end = -1; + for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset--) if (view.getUint32(offset, true) === 0x06054b50 && offset + 22 + view.getUint16(offset + 20, true) === bytes.length) { end = offset; break; } + if (end < 0) fail('INVALID_ARCHIVE', 'ZIP directory is missing or truncated.'); + const count = view.getUint16(end + 10, true), size = view.getUint32(end + 12, true), start = view.getUint32(end + 16, true); + if (view.getUint16(end + 4, true) || view.getUint16(end + 6, true) || count === 65535 || start + size !== end) fail('UNSUPPORTED_ARCHIVE', 'Multipart and ZIP64 archives are unsupported.'); + budget.entries += count; + if (budget.entries > ARCHIVE_LIMITS.entries) fail('ARCHIVE_ENTRY_LIMIT', 'Archive exceeds the cumulative entry count limit.'); + const names = new Set(), checksums = new Map(); let cursor = start; + for (let index = 0; index < count; index++) { + if (cursor + 46 > end || view.getUint32(cursor, true) !== 0x02014b50) fail('INVALID_ARCHIVE', 'ZIP directory entry is invalid.'); + const length = view.getUint16(cursor + 28, true), extra = view.getUint16(cursor + 30, true), comment = view.getUint16(cursor + 32, true); + if (cursor + 46 + length + extra + comment > end) fail('INVALID_ARCHIVE', 'ZIP directory entry is truncated.'); + const name = safePath(decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + length))); + // JSZip takes the filename from the local header. Inspect it before the + // library can overwrite entries with mismatching effective identities. + const local = view.getUint32(cursor + 42, true); + if (local + 30 > start || view.getUint32(local, true) !== 0x04034b50) fail('INVALID_ARCHIVE', 'ZIP local header is invalid.'); + const localLength = view.getUint16(local + 26, true), localExtra = view.getUint16(local + 28, true); + if (local + 30 + localLength + localExtra > start) fail('INVALID_ARCHIVE', 'ZIP local header is truncated.'); + const localName = safePath(decoder.decode(bytes.subarray(local + 30, local + 30 + localLength))); + if (localName !== name) fail('UNSUPPORTED_ARCHIVE_PATH', 'ZIP local and central entry paths must match.', name); + // JSZip interprets Unicode path extras before normalizing names. Reject + // overrides rather than letting them hide duplicate or traversing paths. + let extraCursor = cursor + 46 + length; + const extraEnd = extraCursor + extra; + while (extraCursor < extraEnd) { + if (extraCursor + 4 > extraEnd) fail('INVALID_ARCHIVE', 'Truncated ZIP extra field.'); + const tag = view.getUint16(extraCursor, true), fieldLength = view.getUint16(extraCursor + 2, true); + if (extraCursor + 4 + fieldLength > extraEnd) fail('INVALID_ARCHIVE', 'Truncated ZIP extra field.'); + if (tag === 0x7075) { + if (fieldLength < 5 || safePath(decoder.decode(bytes.subarray(extraCursor + 9, extraCursor + 4 + fieldLength))) !== name) fail('UNSUPPORTED_ARCHIVE_PATH', 'ZIP Unicode path overrides are unsupported.', name); + } + extraCursor += 4 + fieldLength; + } + const identity = name.replace(/\/$/, ''); + if (names.has(identity)) fail('DUPLICATE_ARCHIVE_PATH', 'Archive contains duplicate normalized entry paths.', name); + names.add(identity); checksums.set(name, view.getUint32(cursor + 16, true)); cursor += 46 + length + extra + comment; + } + if (cursor !== end) fail('INVALID_ARCHIVE', 'ZIP directory size does not match its entries.'); + return checksums; +} +async function boundedEntry(entry, budget, expectedCrc) { + return new Promise((resolve, reject) => { + const chunks = []; let length = 0, stopped = false, crc = 0xffffffff; + const stream = entry.internalStream('uint8array'); + stream.on('data', (chunk) => { + if (stopped) return; + length += chunk.length; budget.expanded += chunk.length; + if (budget.expanded > ARCHIVE_LIMITS.expanded || (/\.json$/i.test(entry.name) && length > ARCHIVE_LIMITS.json)) { + stopped = true; stream.pause(); chunks.length = 0; + reject(new ProjectDataError('ARCHIVE_EXPANSION_LIMIT', 'Archive expanded content exceeds its allowed size.', [{ path: entry.name }])); return; + } + for (const byte of chunk) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xff]; + chunks.push(chunk); + }).on('error', reject).on('end', () => { + if (stopped) return; + if (((crc ^ 0xffffffff) >>> 0) !== expectedCrc) { chunks.length = 0; reject(new ProjectDataError('ARCHIVE_CHECKSUM_MISMATCH', 'Archive entry bytes do not match their checksum.', [{ path: entry.name }])); return; } + const bytes = new Uint8Array(length); let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + resolve(bytes); + }).resume(); + }); +} +async function extract(input, budget) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(await input.arrayBuffer()); + if (bytes.length > ARCHIVE_LIMITS.compressed) fail('ARCHIVE_COMPRESSED_LIMIT', 'Archive exceeds the compressed size limit.'); + const checksums = inspectDirectory(bytes, budget); + const zip = await JSZip.loadAsync(bytes), entries = new Map(); + for (const entry of Object.values(zip.files)) if (!entry.dir) { + safePath(entry.unsafeOriginalName || entry.name); + const name = safePath(entry.name); + if (entries.has(name)) fail('DUPLICATE_ARCHIVE_PATH', 'Archive contains duplicate effective entry paths.', name); + entries.set(name, await boundedEntry(entry, budget, checksums.get(name))); + } + return entries; +} +export async function readBoundedArchive(input, budget = { entries: 0, expanded: 0 }) { return extract(input, budget); } +function json(entries, path, fallback) { + if (!entries.has(path)) { if (fallback !== undefined) return fallback; fail('MISSING_ARCHIVE_ENTRY', 'Required archive entry is missing.', path); } + const bytes = entries.get(path); + if (bytes.length > ARCHIVE_LIMITS.json) fail('ARCHIVE_JSON_LIMIT', 'JSON entry exceeds its size limit.', path); + try { return JSON.parse(decoder.decode(bytes)); } catch { fail('INVALID_ARCHIVE_JSON', 'Archive contains invalid JSON.', path); } +} +const empty = (project) => ({ project, ...Object.fromEntries(RECORD_TABLES.map((table) => [table, []])) }); +const filename = (name, id) => `${String(name || 'record').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 70)}--${encodeURIComponent(id)}`; + +export async function exportProjectArchive(input, { kind = 'project', dialogueId } = {}) { + const snapshot = requireValidProject(await canonicalizeProject(input)); + if (snapshot.dialogues.length > ARCHIVE_LIMITS.dialogues) fail('ARCHIVE_DIALOGUE_LIMIT', 'Project has too many dialogues for one backup.'); + const zip = new JSZip(), manifest = { format: 'mountea.archive.v3', kind, dialogueId, project: 'project.json', records: [], media: [] }; + let expanded = 0, count = 0; + const add = (path, bytes) => { + if (path.endsWith('.json') && bytes.length > ARCHIVE_LIMITS.json) fail('ARCHIVE_JSON_LIMIT', 'A record exceeds the JSON entry limit.', path); + expanded += bytes.length; count++; + if (expanded > ARCHIVE_LIMITS.expanded || count > ARCHIVE_LIMITS.entries) fail('ARCHIVE_EXPANSION_LIMIT', 'Project exceeds the restorable archive limits.'); + zip.file(path, bytes, { createFolders: false }); + }; + for (const node of snapshot.nodes) for (const row of node.data?.dialogueRows || []) if (row.audioFile) { + const path = `media/${filename(row.audioFile.name, JSON.stringify([node.dialogueId, node.id, row.id]))}`; + add(path, decodeMediaBase64(row.audioFile.base64)); delete row.audioFile.base64; + manifest.media.push({ path, dialogueId: node.dialogueId, nodeId: node.id, rowId: row.id }); + } + for (const participant of snapshot.participants) if (participant.thumbnail?.base64) { + const path = `media/thumbnails/${encodeURIComponent(participant.id)}`; + add(path, decodeMediaBase64(participant.thumbnail.base64)); delete participant.thumbnail.base64; + manifest.media.push({ path, participantId: participant.id }); + } + add(manifest.project, encode(snapshot.project)); + for (const table of RECORD_TABLES) for (const [index, record] of snapshot[table].entries()) { + const path = `${table}/${filename(record.name || record.key || table, `${record.dialogueId || ''}:${record.id || record.key}:${index}`)}.json`; + add(path, encode(record)); manifest.records.push({ table, path }); + } + add('manifest.json', encode(manifest)); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + if (bytes.length > ARCHIVE_LIMITS.compressed) fail('ARCHIVE_COMPRESSED_LIMIT', 'Backup exceeds the supported compressed size limit.'); + return new Blob([bytes], { type: 'application/zip' }); +} + +async function parseVersioned(entries, manifest) { + if (manifest.format !== 'mountea.archive.v3') fail('UNSUPPORTED_ARCHIVE', 'Unsupported archive format.'); + if (!['project', 'dialogue'].includes(manifest.kind)) fail('INVALID_ARCHIVE_MANIFEST', 'Archive kind must be project or dialogue.'); + if (!Array.isArray(manifest.records) || !Array.isArray(manifest.media)) fail('INVALID_ARCHIVE_MANIFEST', 'Archive manifest lists are invalid.'); + const snapshot = empty(json(entries, manifest.project)); const used = new Set(['manifest.json', manifest.project]); + for (const item of manifest.records) { + if (!RECORD_TABLES.includes(item.table) || used.has(item.path)) fail('INVALID_ARCHIVE_MANIFEST', 'Duplicate or unsupported manifest record.', item.path); + used.add(item.path); snapshot[item.table].push(json(entries, item.path)); + } + const bindings = new Set(); + for (const item of manifest.media) { + const binding = JSON.stringify(item.participantId ? ['thumbnail', item.participantId] : ['audio', item.dialogueId, item.nodeId, item.rowId]); + if (bindings.has(binding)) fail('INVALID_ARCHIVE_MEDIA', 'Multiple media entries claim the same record.', item.path); + bindings.add(binding); + if (item.participantId) { + const participant = snapshot.participants.find((row) => row.id === item.participantId); + if (!participant?.thumbnail || used.has(item.path) || !entries.has(item.path)) fail('INVALID_ARCHIVE_MEDIA', 'Thumbnail manifest reference is missing or duplicated.', item.path); + used.add(item.path); + const media = await serializeAudio({ ...participant.thumbnail, blob: new Blob([entries.get(item.path)], { type: participant.thumbnail.mimeType }) }, item.path); + participant.thumbnail.base64 = media.base64; + continue; + } + const row = snapshot.nodes.find((node) => node.dialogueId === item.dialogueId && node.id === item.nodeId)?.data?.dialogueRows?.find((candidate) => candidate.id === item.rowId); + if (!row?.audioFile || used.has(item.path) || !entries.has(item.path)) fail('INVALID_ARCHIVE_MEDIA', 'Media manifest reference is missing or duplicated.', item.path); + used.add(item.path); row.audioFile = await serializeAudio({ ...row.audioFile, blob: new Blob([entries.get(item.path)], { type: row.audioFile.mimeType }) }, item.path); + } + if (used.size !== entries.size) fail('INVALID_ARCHIVE_MANIFEST', 'Archive has entries not declared by its manifest.'); + if (manifest.kind === 'dialogue' && !snapshot.dialogues.some((row) => row.id === manifest.dialogueId)) fail('INVALID_ARCHIVE_MANIFEST', 'The archive root dialogue is missing.'); + return { snapshot, kind: manifest.kind, dialogueId: manifest.dialogueId }; +} + +function uniqueByName(records, name, path) { + const matches = records.filter((row) => row.name === name); + if (matches.length !== 1) fail('LEGACY_REFERENCE_REPAIR_REQUIRED', 'Legacy reference cannot be resolved uniquely.', path); + return matches[0].id; +} +async function legacySupport(entries, snapshot) { + const projectId = snapshot.project.id, paths = new Map(); + const category = (path) => { + let parentCategoryId = null, full = ''; + for (const name of String(path || '').split('.').filter(Boolean)) { + full = full ? `${full}.${name}` : name; + let row = paths.get(full); + if (!row) { row = { id: crypto.randomUUID(), projectId, name, parentCategoryId }; paths.set(full, row); snapshot.categories.push(row); } + parentCategoryId = row.id; + } + return parentCategoryId; + }; + for (const record of json(entries, 'categories.json', [])) category(record.fullPath || record.name); + for (const record of json(entries, 'participants.json', [])) { + const row = { ...record, id: record.id || crypto.randomUUID(), projectId, categoryId: category(record.fullPath || record.category), category: String(record.fullPath || record.category || '').split('.').pop() }; + if (record.participantImage) { + const path = `Thumbnails/${record.participantImage}.png`; + if (entries.has(path)) { const audio = await serializeAudio({ blob: new Blob([entries.get(path)], { type: 'image/png' }) }, path); row.thumbnail = { dataUrl: audio.base64, mimeType: 'image/png', size: audio.size }; } + } + snapshot.participants.push(row); + } + for (const table of ['decorators', 'conditions']) for (const row of json(entries, `${table}.json`, [])) snapshot[table].push({ ...row, id: row.id || crypto.randomUUID(), projectId, properties: row.properties || [] }); +} +async function legacyDialogue(entries, snapshot) { + const metadata = json(entries, 'dialogueData.json'), id = metadata.dialogueGuid || metadata.id; + if (!id) fail('INVALID_ID', 'Legacy dialogue has no identity.'); + const dialogue = { ...metadata, id, projectId: snapshot.project.id, name: metadata.dialogueName || metadata.name, createdAt: metadata.createdAt, modifiedAt: metadata.modifiedOnDate || metadata.modifiedAt }; + snapshot.dialogues.push(dialogue); + const exportedRows = json(entries, 'dialogueRows.json', []); + for (const node of json(entries, 'nodes.json')) { + node.dialogueId = id; node.data ||= {}; + if (node.data.participant && !node.data.participantId) node.data.participantId = uniqueByName(snapshot.participants, node.data.participant.name || node.data.participant, `nodes.${node.id}.participant`); + for (const instance of node.data.decorators || []) if (!snapshot.decorators.some((definition) => definition.id === instance.id)) instance.id = uniqueByName(snapshot.decorators, instance.name, `nodes.${node.id}.decorators`); + for (const row of node.data.dialogueRows || []) { + row.id ||= crypto.randomUUID(); + const files = [...entries.keys()].filter((path) => path.startsWith(`audio/${row.id}/`) && path.split('/').length === 3); + if (files.length > 1) fail('AMBIGUOUS_MEDIA', 'Multiple audio files claim the same legacy row.', row.id); + if (files.length) row.audioFile = await serializeAudio({ name: files[0].split('/').pop(), blob: new Blob([entries.get(files[0])]) }, files[0]); + else if (exportedRows.some((entry) => entry.id === row.id && entry.audioPath)) fail('MISSING_MEDIA', 'Legacy row refers to absent audio.', row.id); + } + snapshot.nodes.push(node); + } + snapshot.edges.push(...json(entries, 'edges.json').map((edge) => ({ ...edge, dialogueId: id }))); + snapshot.localizedStrings.push(...parseImportedStringTableData(json(entries, 'stringTable.json', null)).map((row) => ({ ...row, projectId: snapshot.project.id, dialogueId: id }))); +} + +export async function parseProjectArchive(input) { + const budget = { entries: 0, expanded: 0 }, entries = await extract(input, budget); + let parsed; + if (entries.has('manifest.json')) parsed = await parseVersioned(entries, json(entries, 'manifest.json')); + else { + const metadata = json(entries, 'projectData.json', null), project = metadata ? { ...metadata, id: metadata.projectGuid || metadata.id || crypto.randomUUID(), name: metadata.projectName || metadata.name, description: metadata.projectDescription || metadata.description || '' } : { id: crypto.randomUUID(), name: 'Imported dialogue' }; + const snapshot = empty(project); await legacySupport(entries, snapshot); + if (metadata) { + const children = [...entries].filter(([path]) => path.startsWith('dialogues/') && path.endsWith('.mnteadlg')); + if (children.length > ARCHIVE_LIMITS.dialogues) fail('ARCHIVE_DIALOGUE_LIMIT', 'Archive has too many dialogues.'); + for (const [, bytes] of children) { const child = await extract(bytes, budget); await legacyDialogue(child, snapshot); } + } else await legacyDialogue(entries, snapshot); + parsed = { snapshot: remapProjectIdentities(snapshot, { projectId: project.id, preserveEntityIds: true }).snapshot, kind: metadata ? 'project' : 'dialogue', dialogueId: snapshot.dialogues[0]?.id }; + } + if (parsed.snapshot.dialogues.length > ARCHIVE_LIMITS.dialogues) fail('ARCHIVE_DIALOGUE_LIMIT', 'Archive has too many dialogues.'); + parsed.snapshot = requireValidProject(await canonicalizeProject(parsed.snapshot)); + return { ...parsed, diagnostics: [], expandedBytes: budget.expanded }; +} + +/** Pure preparation with respect to authoring data; only commitPreparedProject writes. */ +export async function prepareArchiveImport(input, { context: suppliedContext, mode = 'copy', projectId, dialogueId, isExample } = {}) { + const context = suppliedContext || await getRepositoryContext(); + if (!['copy', 'replace'].includes(mode)) fail('INVALID_IMPORT_MODE', 'Choose copy or explicit replacement.'); + const destination = projectId ? await readProjectState(projectId, context) : null; + const parsed = await parseProjectArchive(input); context.assertCurrent(); + let snapshot, importedRootId, expectedSequence = 0, operation = 'create'; + if (parsed.kind === 'dialogue') { + if (!destination?.snapshot.project) fail('PROJECT_NOT_FOUND', 'Choose a destination project for this dialogue.'); + if (mode === 'replace' && (!dialogueId || !destination.snapshot.dialogues.some((row) => row.id === dialogueId))) fail('INVALID_REPLACEMENT_TARGET', 'Explicit replacement requires a dialogue in the destination project.'); + const replacements = mode === 'replace' ? new Map([[parsed.dialogueId, dialogueId]]) : new Map(); + const imported = remapProjectIdentities(parsed.snapshot, { projectId, dialogueIds: replacements, existingDialogues: destination.snapshot.dialogues.filter((row) => row.id !== dialogueId) }); + importedRootId = imported.maps.dialogues.get(parsed.dialogueId); + snapshot = structuredClone(destination.snapshot); + for (const table of RECORD_TABLES) { + if (mode === 'replace' && ['dialogues', 'nodes', 'edges', 'localizedStrings'].includes(table)) snapshot[table] = snapshot[table].filter((row) => (table === 'dialogues' ? row.id : row.dialogueId) !== dialogueId); + snapshot[table].push(...imported.snapshot[table]); + } + expectedSequence = destination.sequence; operation = 'update'; + } else if (mode === 'replace') { + if (!destination?.snapshot.project) fail('INVALID_REPLACEMENT_TARGET', 'Explicit replacement requires an existing destination project.'); + snapshot = remapProjectIdentities(parsed.snapshot, { projectId }).snapshot; + expectedSequence = destination.sequence; operation = 'update'; + } else snapshot = remapProjectIdentities(parsed.snapshot).snapshot; + if (isExample !== undefined) snapshot.project.isExample = Boolean(isExample); + const prepared = await prepareProjectCommit(snapshot, { context, expectedSequence, operation }); + return { prepared, diagnostics: parsed.diagnostics, projectId: snapshot.project.id, firstDialogueId: importedRootId || snapshot.dialogues[0]?.id }; +} diff --git a/src/lib/persistence/projectRemap.js b/src/lib/persistence/projectRemap.js new file mode 100644 index 0000000..db69e3a --- /dev/null +++ b/src/lib/persistence/projectRemap.js @@ -0,0 +1,76 @@ +import { RECORD_TABLES, START_NODE_ID, compoundIdentity, ProjectDataError } from './canonicalProject'; +import { allocateDialogueLocalizationSlug, prepareLocalizedNodesAndEntries } from '@/lib/localization/stringTable'; + +/** Allocate every identity before applying references, including forward edges, + * return targets, child graphs, definitions, row audio and localization scopes. */ +export function remapProjectIdentities(input, { projectId = crypto.randomUUID(), dialogueIds = new Map(), existingDialogues = [], preserveEntityIds = false } = {}) { + const snapshot = structuredClone(input), maps = {}; + for (const table of ['dialogues', 'categories', 'participants', 'decorators', 'conditions']) maps[table] = new Map((input[table] || []).map((row) => [row.id, table === 'dialogues' && dialogueIds.has(row.id) ? dialogueIds.get(row.id) : preserveEntityIds ? row.id : crypto.randomUUID()])); + maps.nodes = new Map(); maps.rows = new Map(); + for (const node of input.nodes || []) { + maps.nodes.set(compoundIdentity(node.dialogueId, node.id), ['start', 'startNode'].includes(node.type) ? START_NODE_ID : preserveEntityIds ? node.id : crypto.randomUUID()); + for (const row of node.data?.dialogueRows || []) maps.rows.set(JSON.stringify([node.dialogueId, node.id, row.id]), preserveEntityIds ? row.id : crypto.randomUUID()); + } + const remap = (table, id) => maps[table]?.get(id) ?? id; + const remapNode = (dialogueId, id) => { + const exact = maps.nodes.get(compoundIdentity(dialogueId, id)); + if (exact) return exact; + // Legacy UUID casing may differ on forward references. Never use a + // case-insensitive match when more than one identity could match. + const matches = (input.nodes || []).filter((node) => node.dialogueId === dialogueId && String(node.id).toLowerCase() === String(id).toLowerCase()); + if (matches.length > 1) throw new ProjectDataError('AMBIGUOUS_NODE_ID', 'A case-insensitive legacy node reference has multiple matches.'); + return matches.length === 1 ? maps.nodes.get(compoundIdentity(dialogueId, matches[0].id)) : id; + }; + snapshot.project.id = projectId; + for (const table of RECORD_TABLES) for (const row of snapshot[table] || []) { + if (table !== 'nodes' && table !== 'edges') row.projectId = projectId; + if (maps[table] && table !== 'nodes') row.id = remap(table, row.id); + } + for (const row of snapshot.categories || []) if (row.parentCategoryId) row.parentCategoryId = remap('categories', row.parentCategoryId); + for (const row of snapshot.participants || []) if (row.categoryId) row.categoryId = remap('categories', row.categoryId); + for (const node of snapshot.nodes || []) { + const oldDialogue = node.dialogueId, oldId = node.id; + node.id = remapNode(oldDialogue, oldId); node.dialogueId = remap('dialogues', oldDialogue); + const data = node.data || {}; + if (data.participantId) data.participantId = remap('participants', data.participantId); + if (data.targetNode) data.targetNode = remapNode(oldDialogue, data.targetNode); + if (data.targetDialogue) data.targetDialogue = remap('dialogues', data.targetDialogue); + for (const instance of data.decorators || []) instance.id = remap('decorators', instance.id); + for (const row of data.dialogueRows || []) { + row.id = maps.rows.get(JSON.stringify([oldDialogue, oldId, row.id])) ?? row.id; + if (row.participantId) row.participantId = remap('participants', row.participantId); + } + } + for (const edge of snapshot.edges || []) { + const oldDialogue = edge.dialogueId; + edge.id = preserveEntityIds ? edge.id : crypto.randomUUID(); edge.dialogueId = remap('dialogues', oldDialogue); + edge.source = remapNode(oldDialogue, edge.source); edge.target = remapNode(oldDialogue, edge.target); + for (const rule of edge.data?.conditions?.rules || []) rule.id = remap('conditions', rule.id); + } + for (const entry of snapshot.localizedStrings || []) { + const oldDialogue = entry.dialogueId, oldNode = entry.nodeId; + entry.dialogueId = remap('dialogues', oldDialogue); entry.nodeId = remapNode(oldDialogue, oldNode); + if (entry.rowId) entry.rowId = maps.rows.get(JSON.stringify([oldDialogue, oldNode, entry.rowId])) ?? entry.rowId; + } + const allocated = [...existingDialogues]; + for (const dialogue of snapshot.dialogues || []) { + const oldSlug = dialogue.localizationSlug; + dialogue.localizationSlug = allocateDialogueLocalizationSlug(dialogue, allocated); allocated.push(dialogue); + const nodes = snapshot.nodes.filter((node) => node.dialogueId === dialogue.id); + const existingEntries = snapshot.localizedStrings.filter((entry) => entry.dialogueId === dialogue.id); + const prepared = prepareLocalizedNodesAndEntries({ projectId, dialogueId: dialogue.id, dialogueSlug: dialogue.localizationSlug, nodes, locale: snapshot.project.localization?.defaultLocale || 'en', existingEntries }); + snapshot.nodes = snapshot.nodes.filter((node) => node.dialogueId !== dialogue.id).concat(prepared.nodes); + // Keep unreferenced authored translation entries too: complete backups must + // not prune them as a side effect of cloning. + const referencedOldKeys = new Set(nodes.flatMap((node) => [node.data?.displayNameKey, node.data?.selectionTitleKey, ...(node.data?.dialogueRows || []).map((row) => row.textKey)]).filter(Boolean)); + const preparedEntries = prepared.entries.map((entry) => ({ ...existingEntries.find((original) => original.nodeId === entry.nodeId && (original.rowId || '') === (entry.rowId || '') && original.field === entry.field), ...entry })); + const unusedEntries = existingEntries.filter((entry) => !referencedOldKeys.has(entry.key) && !prepared.entries.some((newEntry) => newEntry.key === entry.key)).map((entry) => { + const oldNamespace = `dlg.${oldSlug || entry.dialogueSlug}.`; + const key = entry.key.startsWith(oldNamespace) ? `dlg.${dialogue.localizationSlug}.${entry.key.slice(oldNamespace.length)}` : preserveEntityIds ? entry.key : `dlg.${dialogue.localizationSlug}.unused_${crypto.randomUUID().replaceAll('-', '_')}`; + return { ...entry, key, dialogueSlug: dialogue.localizationSlug }; + }); + snapshot.localizedStrings = snapshot.localizedStrings.filter((entry) => entry.dialogueId !== dialogue.id).concat(preparedEntries, unusedEntries); + dialogue.localizationVersion = 2; + } + return { snapshot, maps }; +} diff --git a/src/lib/persistence/projectRepository.js b/src/lib/persistence/projectRepository.js new file mode 100644 index 0000000..5adf058 --- /dev/null +++ b/src/lib/persistence/projectRepository.js @@ -0,0 +1,133 @@ +import { getRepositoryContext, readProjectRecords } from '@/lib/db'; +import { AUTHORING_TABLES } from './schema'; +import { canonicalizeProject, recordsToSnapshot, snapshotToRecords, validateCanonicalProject, hashProject, stableJson, ProjectDataError } from './canonicalProject'; + +const COMMIT_TABLES = [...AUTHORING_TABLES, 'projectState', 'projectRevisions', 'syncOutbox', 'recoveryRecords']; +const preparedCommits = new WeakSet(); +const mutationQueues = new WeakMap(); +function deviceId() { + const key = 'mountea-revision-device-id'; + let value = localStorage.getItem(key); + if (!value) { value = crypto.randomUUID(); localStorage.setItem(key, value); } + return value; +} +function freeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const child of Object.values(value)) freeze(child); } + return value; +} + +export async function readProjectState(projectId, suppliedContext) { + const context = suppliedContext || await getRepositoryContext(); context.assertCurrent(); + const result = await context.db.transaction('r', [...AUTHORING_TABLES, 'projectState'].map((table) => context.db.table(table)), async () => { + const records = await readProjectRecords(projectId, context); + const state = await context.db.projectState.get(projectId); + return { snapshot: recordsToSnapshot(records), sequence: state?.sequence || 0, revisionId: state?.revisionId || null, state }; + }); + context.assertCurrent(); return result; +} + +/** All async media conversion/hashing happens here, outside IndexedDB. */ +export async function prepareProjectCommit(input, { context: suppliedContext, expectedSequence, operation = 'update', parentRevisionIds, revisionId = crypto.randomUUID(), validation = 'strict', existingDiagnostics = [], enqueue = true, recoveryResolutions = [], remoteRevision } = {}) { + const context = suppliedContext || await getRepositoryContext(); context.assertCurrent(); + if (remoteRevision) { + if (enqueue) throw new ProjectDataError('INVALID_REMOTE_REVISION', 'Remote revisions cannot enqueue a local edit.'); + operation = remoteRevision.operation; revisionId = remoteRevision.id; parentRevisionIds = remoteRevision.parentRevisionIds; + } + if (!Number.isInteger(expectedSequence) || expectedSequence < 0) throw new ProjectDataError('EXPECTED_SEQUENCE_REQUIRED', 'A captured nonnegative project sequence is required.'); + if (!['update', 'create', 'delete', 'restore', 'resolve'].includes(operation)) throw new ProjectDataError('INVALID_OPERATION', 'Unsupported project operation.'); + const snapshot = await canonicalizeProject(input, { allowIncompleteMedia: validation === 'draft' }); + const diagnostics = validateCanonicalProject(snapshot, { localization: validation !== 'draft' }); + const known = new Set(existingDiagnostics.map((item) => `${item.code}:${item.path}`)); + const errors = validation === 'draft' + ? diagnostics.filter((item) => !['INVALID_START', 'UNRESOLVED_PARTICIPANT', 'UNRESOLVED_CATEGORY'].includes(item.code) && !known.has(`${item.code}:${item.path}`)) + : diagnostics; + if (errors.length && operation !== 'delete') throw new ProjectDataError('INVALID_PROJECT_DATA', errors[0].message, errors); + const projectId = snapshot.project.id; + const payloadHash = await hashProject(snapshot); + const current = await context.db.projectState.get(projectId); context.assertCurrent(); + const parents = parentRevisionIds ?? (current?.revisionId ? [current.revisionId] : []); + if (!Array.isArray(parents) || parents.some((id) => typeof id !== 'string' || !id)) throw new ProjectDataError('INVALID_REVISION_PARENTS', 'Revision parents must be nonempty identifiers.'); + if (remoteRevision && (remoteRevision.projectId !== projectId || remoteRevision.payloadHash !== payloadHash || typeof revisionId !== 'string' || !revisionId || typeof remoteRevision.deviceId !== 'string' || !remoteRevision.deviceId || typeof remoteRevision.createdAt !== 'string' || !Number.isFinite(Date.parse(remoteRevision.createdAt)))) throw new ProjectDataError('INVALID_REMOTE_REVISION', 'Remote revision identity, metadata or payload hash is invalid.'); + const revision = freeze(remoteRevision ? { ...structuredClone(remoteRevision), snapshot } : { id: revisionId, projectId, parentRevisionIds: [...new Set(parents)], deviceId: deviceId(), payloadHash, operation, snapshot, createdAt: new Date().toISOString() }); + const prepared = Object.freeze({ context, projectId, expectedSequence, revision, records: freeze(snapshotToRecords(snapshot)), enqueue, recoveryResolutions: freeze(structuredClone(recoveryResolutions)) }); + preparedCommits.add(prepared); return prepared; +} + +async function assertOwnership(database, prepared) { + for (const table of ['dialogues', 'participants', 'categories', 'decorators', 'conditions']) { + const ids = prepared.records[table].map((row) => row.id); + for (const record of await database[table].bulkGet(ids)) if (record && record.projectId !== prepared.projectId) throw new ProjectDataError('FOREIGN_OWNERSHIP', `An imported ${table} identity already belongs to another project.`, [{ table, id: record.id, projectId: record.projectId }]); + } +} + +export async function commitPreparedProject(prepared) { + if (!preparedCommits.has(prepared)) throw new ProjectDataError('INVALID_PREPARED_COMMIT', 'Prepare this project in the current session before committing.'); + const { context, projectId, expectedSequence, revision } = prepared; + context.assertCurrent(); + const result = await context.db.transaction('rw', COMMIT_TABLES.map((table) => context.db.table(table)), async () => { + context.assertCurrent(); + const current = await context.db.projectState.get(projectId); + if ((current?.sequence || 0) !== expectedSequence) throw new ProjectDataError('STALE_PROJECT', 'This project changed while the operation was being prepared. Reload and retry.'); + if (revision.operation === 'create' && await context.db.projects.get(projectId)) throw new ProjectDataError('PROJECT_EXISTS', 'The destination project already exists. Choose copy or explicit replacement.'); + await assertOwnership(context.db, prepared); + for (const resolution of prepared.recoveryResolutions) { + const diagnostic = await context.db.recoveryRecords.get(resolution.id); + if (!diagnostic || diagnostic.projectId !== projectId) throw new ProjectDataError('FOREIGN_RECOVERY_RECORD', 'Repair diagnostic does not belong to this project.'); + await context.db.recoveryRecords.update(resolution.id, { status: 'resolved', restoredKey: resolution.restoredKey }); + } + const previousProject = await context.db.projects.get(projectId); + const previousDialogues = new Map((await context.db.dialogues.where('projectId').equals(projectId).toArray()).map((dialogue) => [dialogue.id, dialogue])); + const dialogues = await context.db.dialogues.where('projectId').equals(projectId).primaryKeys(); + for (const table of ['nodes', 'edges']) if (dialogues.length) await context.db[table].where('dialogueId').anyOf(dialogues).delete(); + for (const table of AUTHORING_TABLES.filter((name) => !['projects', 'nodes', 'edges'].includes(name))) await context.db[table].where('projectId').equals(projectId).delete(); + await context.db.projects.delete(projectId); + if (revision.operation !== 'delete') for (const table of AUTHORING_TABLES) if (prepared.records[table].length) { + const records = prepared.records[table].map((record) => { + const previous = table === 'projects' ? previousProject : table === 'dialogues' ? previousDialogues.get(record.id) : null; + return previous?.lastExportPath ? { ...record, lastExportPath: previous.lastExportPath } : record; + }); + await context.db[table].bulkAdd(records); + } + const existingRevision = await context.db.projectRevisions.get(revision.id); + if (existingRevision) { + if (stableJson(existingRevision) !== stableJson(revision)) throw new ProjectDataError('REVISION_ID_COLLISION', 'An immutable revision identity already has different content.'); + } else await context.db.projectRevisions.add(revision); + const sequence = expectedSequence + 1; + await context.db.projectState.put({ projectId, sequence, revisionId: revision.id, modifiedAt: revision.createdAt, deleted: revision.operation === 'delete' }); + // Intent is provider-independent and durable even while offline/disconnected. + // Per-provider delivery records acknowledge only this immutable revision. + if (prepared.enqueue) await context.db.syncOutbox.add({ id: revision.id, projectId, localRevisionId: revision.id, payloadHash: revision.payloadHash, status: 'pending', operation: revision.operation, createdAt: revision.createdAt }); + context.assertCurrent(); return { projectId, revisionId: revision.id, sequence, snapshot: revision.snapshot }; + }); + preparedCommits.delete(prepared); context.assertCurrent(); return result; +} + +export async function applyRemoteRevision(revision, { context: suppliedContext, expectedSequence } = {}) { + const context = suppliedContext || await getRepositoryContext(); + const current = await readProjectState(revision?.projectId, context); + const prepared = await prepareProjectCommit(revision?.snapshot, { context, expectedSequence: expectedSequence ?? current.sequence, enqueue: false, remoteRevision: revision }); + return commitPreparedProject(prepared); +} + +export async function mutateProject(projectId, { context: suppliedContext, transform, operation = 'update', parentRevisionIds, recoveryResolutions } = {}) { + const context = suppliedContext || await getRepositoryContext(); + context.assertCurrent(); + let queues = mutationQueues.get(context.db); + if (!queues) { queues = new Map(); mutationQueues.set(context.db, queues); } + const previous = queues.get(projectId) || Promise.resolve(); + const perform = async () => { + context.assertCurrent(); + const current = await readProjectState(projectId, context); + if (!current.snapshot.project) throw new ProjectDataError('PROJECT_NOT_FOUND', 'Project not found.'); + const snapshot = structuredClone(current.snapshot); + const transformed = transform ? await transform(snapshot) : snapshot; + context.assertCurrent(); + const prepared = await prepareProjectCommit(transformed ?? snapshot, { context, expectedSequence: current.sequence, operation, parentRevisionIds, recoveryResolutions, validation: 'draft', existingDiagnostics: validateCanonicalProject(current.snapshot, { localization: false }) }); + return commitPreparedProject(prepared); + }; + const work = previous.catch(() => {}).then(() => navigator.locks?.request + ? navigator.locks.request(`mountea-project:${context.db.name}:${projectId}`, { signal: context.signal }, perform) + : perform()); + queues.set(projectId, work); + try { return await work; } finally { if (queues.get(projectId) === work) queues.delete(projectId); } +} diff --git a/src/lib/persistence/schema.js b/src/lib/persistence/schema.js new file mode 100644 index 0000000..50b0d8d --- /dev/null +++ b/src/lib/persistence/schema.js @@ -0,0 +1,24 @@ +export const AUTHORING_TABLES = ['projects', 'dialogues', 'participants', 'categories', 'decorators', 'conditions', 'nodes', 'edges', 'localizedStrings']; +// New generation: never attempt to alter a historical primary key in place. +export const DATABASE_SCHEMA = { + projects: 'id,name,createdAt,modifiedAt', + dialogues: 'id,projectId,name,createdAt,modifiedAt', + participants: 'id,projectId,name,category,categoryId', + categories: 'id,projectId,name,parentCategoryId', + decorators: 'id,projectId,name,type', + conditions: 'id,projectId,name,type', + nodes: '[dialogueId+id],dialogueId,type', + edges: '[dialogueId+id],dialogueId,source,target', + localizedStrings: '[projectId+key],projectId,dialogueId,nodeId,rowId,field,modifiedAt', + syncAccounts: 'provider,accountId,email,expiresAt', + syncProjects: '[projectId+provider],projectId,provider,revision,remoteFileId,lastSyncedAt', + syncDeletions: '[projectId+provider],projectId,provider,deletedAt', + syncCatalogState: '[provider+profileId],provider,profileId,catalogRevision,fetchedAt,status', + syncTombstones: '[provider+entityType+entityId],provider,entityType,entityId,projectId,deletedAt,expiresAt,pending,acknowledgedAt', + projectState: 'projectId,revisionId,sequence,modifiedAt', + projectRevisions: 'id,projectId,createdAt', + syncOutbox: 'id,[provider+projectId],projectId,provider,status,createdAt', + syncConflicts: 'id,projectId,provider,status,createdAt', + recoveryRecords: 'id,projectId,table,code,status', + migrationState: 'key', +}; diff --git a/src/lib/profile/activeProfile.js b/src/lib/profile/activeProfile.js index 7fe4f3f..c15c46b 100644 --- a/src/lib/profile/activeProfile.js +++ b/src/lib/profile/activeProfile.js @@ -1,10 +1,22 @@ const ACTIVE_PROFILE_KEY = 'mountea-active-profile-id'; const DEFAULT_PROFILE_ID = 'local'; +let generation = 0; +const listeners = new Set(); +export function getProfileGeneration() { return generation; } +export function subscribeProfileChanges(listener) { listeners.add(listener); return () => listeners.delete(listener); } +function notifyProfileChange(profileId) { + generation += 1; + for (const listener of listeners) listener(profileId, generation); +} +if (typeof window !== 'undefined') window.addEventListener('storage', (event) => { + if (event.key === ACTIVE_PROFILE_KEY) notifyProfileChange(getActiveProfileId()); +}); function sanitizeProfileId(rawValue) { const value = String(rawValue || '').trim(); if (!value) return DEFAULT_PROFILE_ID; - return value.replace(/[^a-zA-Z0-9_-]/g, '-'); + if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error('Invalid profile identifier. Use letters, numbers, underscores, or hyphens; profile identifiers must not alias another profile.'); + return value; } function canUseStorage() { @@ -20,7 +32,9 @@ export function getActiveProfileId() { export function setActiveProfileId(profileId) { if (!canUseStorage()) return DEFAULT_PROFILE_ID; const normalized = sanitizeProfileId(profileId); + const previous = getActiveProfileId(); window.localStorage.setItem(ACTIVE_PROFILE_KEY, normalized); + if (previous !== normalized) notifyProfileChange(normalized); return normalized; } @@ -50,6 +64,9 @@ export function readProfileScopedItem(baseKey, fallback = '') { if (scopedValue !== null) return scopedValue; // Legacy fallback for pre-profile data; migrate on first read. + // Global historical settings belong to the local profile, never every Steam + // account that happens to sign in on this machine. + if (getActiveProfileId() !== DEFAULT_PROFILE_ID) return fallback; const legacyValue = window.localStorage.getItem(baseKey); if (legacyValue === null) return fallback; window.localStorage.setItem(scopedKey, legacyValue); diff --git a/src/lib/recoveryEvidence.js b/src/lib/recoveryEvidence.js new file mode 100644 index 0000000..df3cf8b --- /dev/null +++ b/src/lib/recoveryEvidence.js @@ -0,0 +1,20 @@ +import { serializeAudio } from '@/lib/persistence/canonicalProject'; + +/** Keep binary evidence durable before presenting it as editable/downloadable JSON. */ +export async function serializeRecoveryEvidence(value) { + if (value instanceof Blob) return { recoveredBlob: await serializeAudio({ blob: value }, 'recovery.original'), ...(value instanceof File ? { name: value.name, lastModified: value.lastModified } : {}) }; + if (value instanceof Date) return { recoveredType: 'Date', value: Number.isNaN(value.getTime()) ? null : value.toISOString() }; + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + let binary = ''; for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768)); + return { recoveredType: value.constructor.name, base64: btoa(binary), byteLength: bytes.length }; + } + if (!value || typeof value !== 'object') return value; + if (value.blob instanceof Blob) { + const original = Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [key, await serializeRecoveryEvidence(item)]))); + const audio = await serializeAudio({ blob: value.blob }, 'recovery.original.audioFile'); + return { ...original, ...audio, recoveredOriginal: original }; + } + if (Array.isArray(value)) return Promise.all(value.map(serializeRecoveryEvidence)); + return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [key, await serializeRecoveryEvidence(item)]))); +} diff --git a/src/lib/storageUtils.js b/src/lib/storageUtils.js index ec85e2a..7ac1657 100644 --- a/src/lib/storageUtils.js +++ b/src/lib/storageUtils.js @@ -1,128 +1,32 @@ -import { db } from './db'; +import { getRepositoryContext } from './db'; +import { buildProjectSnapshot } from './sync/snapshot'; -/** - * Calculate the estimated size of IndexedDB storage - * Uses StorageManager API when available - */ +/** Origin usage includes databases, indexes and caches; it is not project payload size. */ export async function calculateDiskUsage() { - try { - // Try using StorageManager API if available - if ('storage' in navigator && 'estimate' in navigator.storage) { - const estimate = await navigator.storage.estimate(); - return estimate.usage || 0; - } - - // Fallback: Estimate based on data size - return await estimateDataSize(); - } catch (error) { - console.error('Error calculating disk usage:', error); - return 0; - } + try { return (await navigator.storage?.estimate?.())?.usage ?? null; } + catch { return null; } } -/** - * Estimate data size by counting records and approximating - */ -async function estimateDataSize() { - try { - const [ - projectCount, - dialogueCount, - nodeCount, - edgeCount, - participantCount, - categoryCount, - decoratorCount, - ] = await Promise.all([ - db.projects.count(), - db.dialogues.count(), - db.nodes.count(), - db.edges.count(), - db.participants.count(), - db.categories.count(), - db.decorators.count(), - ]); - - // Rough estimates (in bytes): - // Project: ~500 bytes - // Dialogue: ~300 bytes - // Node: ~200 bytes - // Edge: ~100 bytes - // Participant/Category/Decorator: ~150 bytes each - - const estimatedSize = - projectCount * 500 + - dialogueCount * 300 + - nodeCount * 200 + - edgeCount * 100 + - (participantCount + categoryCount + decoratorCount) * 150; - - return estimatedSize; - } catch (error) { - console.error('Error estimating data size:', error); - return 0; - } +export function serializedPayloadBytes(payload) { + return new TextEncoder().encode(JSON.stringify(payload)).byteLength; } -/** - * Calculate size of a specific project including all related data - */ +/** Canonical UTF-8 JSON with encoded media, strings and all unused definitions. + * This is a payload estimate, not ZIP size or allocated IndexedDB disk usage. */ export async function calculateProjectSize(projectId) { - try { - // Get all dialogues for this project - const dialogues = await db.dialogues.where('projectId').equals(projectId).toArray(); - const dialogueIds = dialogues.map((d) => d.id); - - // Count nodes and edges for these dialogues - let nodeCount = 0; - let edgeCount = 0; - - for (const dialogueId of dialogueIds) { - const nodes = await db.nodes.where('dialogueId').equals(dialogueId).count(); - const edges = await db.edges.where('dialogueId').equals(dialogueId).count(); - nodeCount += nodes; - edgeCount += edges; - } - - // Count project-specific data - const participantCount = await db.participants - .where('projectId') - .equals(projectId) - .count(); - const categoryCount = await db.categories.where('projectId').equals(projectId).count(); - const decoratorCount = await db.decorators.where('projectId').equals(projectId).count(); - - // Calculate estimated size - const estimatedSize = - 500 + // Project itself - dialogues.length * 300 + - nodeCount * 200 + - edgeCount * 100 + - (participantCount + categoryCount + decoratorCount) * 150; - - return estimatedSize; - } catch (error) { - console.error('Error calculating project size:', error); - return 0; - } + return serializedPayloadBytes(await buildProjectSnapshot(projectId)); } -/** - * Calculate size of a specific dialogue including nodes and edges - */ export async function calculateDialogueSize(dialogueId) { - try { - const nodeCount = await db.nodes.where('dialogueId').equals(dialogueId).count(); - const edgeCount = await db.edges.where('dialogueId').equals(dialogueId).count(); - - // Dialogue + nodes + edges - const estimatedSize = 300 + nodeCount * 200 + edgeCount * 100; - - return estimatedSize; - } catch (error) { - console.error('Error calculating dialogue size:', error); - return 0; - } + const context = await getRepositoryContext(); + const dialogue = await context.db.dialogues.get(dialogueId); + if (!dialogue) return 0; + const snapshot = await buildProjectSnapshot(dialogue.projectId, { context }); + snapshot.dialogues = snapshot.dialogues.filter((row) => row.id === dialogueId); + for (const table of ['nodes', 'edges']) snapshot[table] = snapshot[table].filter((row) => row.dialogueId === dialogueId); + snapshot.localizedStrings = snapshot.localizedStrings.filter((row) => !row.dialogueId || row.dialogueId === dialogueId); + context.assertCurrent(); + return serializedPayloadBytes(snapshot); } /** From 24d65fb28e4dd3ff9324b91b17f59022f95b3698 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:22 +0200 Subject: [PATCH 11/22] U: Refactor entity stores Move shared store behavior into createDefinitionStore and domainStoreSupport; slim category, condition, decorator, participant, dialogue and project stores. --- src/stores/categoryStore.js | 501 +-------- src/stores/conditionStore.js | 176 +--- src/stores/createDefinitionStore.js | 49 + src/stores/decoratorStore.js | 190 +--- src/stores/dialogueStore.js | 1473 ++++----------------------- src/stores/domainStoreSupport.js | 44 + src/stores/participantStore.js | 634 ++---------- src/stores/projectStore.js | 840 +++------------ 8 files changed, 620 insertions(+), 3287 deletions(-) create mode 100644 src/stores/createDefinitionStore.js create mode 100644 src/stores/domainStoreSupport.js diff --git a/src/stores/categoryStore.js b/src/stores/categoryStore.js index 795ced4..b99fc92 100644 --- a/src/stores/categoryStore.js +++ b/src/stores/categoryStore.js @@ -1,453 +1,54 @@ import { create } from 'zustand'; -import { db } from '@/lib/db'; -import { v4 as uuidv4 } from 'uuid'; -import { toast } from '@/components/ui/toaster'; -import { useSyncStore } from '@/stores/syncStore'; +import { getRepositoryContext } from '@/lib/db'; import { trackFirstCategoryCreated } from '@/lib/achievements/achievementTracker'; - -export const useCategoryStore = create((set, get) => ({ - categories: [], - isLoading: false, - maxCategoryDepth: 5, - maxCategoryNameLength: 16, - - isCategoryNameValid: (name) => { - if (!name) return false; - if (!/^[A-Za-z0-9]+$/.test(name)) return false; - if (name.length > get().maxCategoryNameLength) return false; - return true; - }, - - getRootCategoryId: (categoryId, categoriesList) => { - let currentId = categoryId; - const visited = new Set(); - while (currentId) { - if (visited.has(currentId)) return null; - visited.add(currentId); - const current = categoriesList.find((c) => c.id === currentId); - if (!current) return null; - if (!current.parentCategoryId) return current.id; - currentId = current.parentCategoryId; - } - return null; - }, - - isNameUniqueInTree: (name, rootId, categoriesList, excludeId = null) => { - if (!rootId) return true; - const stack = [rootId]; - const visited = new Set(); - - while (stack.length > 0) { - const currentId = stack.pop(); - if (!currentId || visited.has(currentId)) continue; - visited.add(currentId); - const current = categoriesList.find((c) => c.id === currentId); - if (!current) continue; - if (current.id !== excludeId && current.name === name) { - return false; - } - categoriesList - .filter((c) => c.parentCategoryId === current.id) - .forEach((child) => stack.push(child.id)); - } - - return true; - }, - - getCategoryDepth: (categoryId, categoriesList) => { - let depth = 0; - let currentId = categoryId; - const visited = new Set(); - - while (currentId) { - if (visited.has(currentId)) { - return Number.POSITIVE_INFINITY; - } - visited.add(currentId); - const current = categoriesList.find((c) => c.id === currentId); - if (!current) break; - depth += 1; - currentId = current.parentCategoryId || null; - } - - return depth; - }, - - getMaxSubtreeDepth: (categoryId, categoriesList) => { - const childrenByParent = new Map(); - categoriesList.forEach((category) => { - const parentId = category.parentCategoryId || null; - if (!childrenByParent.has(parentId)) { - childrenByParent.set(parentId, []); - } - childrenByParent.get(parentId).push(category.id); - }); - - const visited = new Set(); - - const dfs = (nodeId) => { - if (visited.has(nodeId)) return 0; - visited.add(nodeId); - const children = childrenByParent.get(nodeId) || []; - if (children.length === 0) return 1; - let maxChildDepth = 0; - children.forEach((childId) => { - maxChildDepth = Math.max(maxChildDepth, dfs(childId)); - }); - return 1 + maxChildDepth; - }; - - return dfs(categoryId); - }, - - // Load categories for a specific project - loadCategories: async (projectId) => { - set({ isLoading: true }); - try { - const categories = await db.categories - .where('projectId') - .equals(projectId) - .toArray(); - set({ categories, isLoading: false }); - } catch (error) { - console.error('Failed to load categories:', error); - toast({ - variant: 'error', - title: 'Failed to Load Categories', - description: error.message || 'An unexpected error occurred', - }); - set({ isLoading: false }); - } - }, - - // Create a new category - createCategory: async (categoryData) => { - try { - const name = categoryData.name?.trim() || ''; - if (!name) { - throw new Error('Category name is required'); - } - if (!/^[A-Za-z0-9]+$/.test(name)) { - throw new Error('Category name must contain only letters and numbers'); - } - if (name.length > get().maxCategoryNameLength) { - throw new Error(`Category name must be ${get().maxCategoryNameLength} characters or fewer`); - } - - const categories = await db.categories - .where('projectId') - .equals(categoryData.projectId) - .toArray(); - const maxDepth = get().maxCategoryDepth; - if (categoryData.parentCategoryId) { - const parentDepth = get().getCategoryDepth(categoryData.parentCategoryId, categories); - if (!Number.isFinite(parentDepth) || parentDepth + 1 > maxDepth) { - throw new Error(`Category depth cannot exceed ${maxDepth} levels.`); - } - } - const parentRootId = categoryData.parentCategoryId - ? get().getRootCategoryId(categoryData.parentCategoryId, categories) - : null; - const rootId = parentRootId || null; - const isUnique = rootId - ? get().isNameUniqueInTree(categoryData.name, rootId, categories) - : !categories.some( - (cat) => !cat.parentCategoryId && cat.name === categoryData.name - ); - if (!isUnique) { - throw new Error('Category name must be unique within its tree.'); - } - - const now = new Date().toISOString(); - const id = uuidv4(); - const newCategory = { - id, - ...categoryData, - createdAt: now, - modifiedAt: now, - }; - - await db.categories.add(newCategory); - try { - await trackFirstCategoryCreated(categoryData.projectId); - } catch (error) { - console.warn('[achievements] Failed to track first category:', error); - } - await get().loadCategories(categoryData.projectId); - useSyncStore.getState().schedulePush(categoryData.projectId); - toast({ - variant: 'success', - title: 'Category Created', - description: `${categoryData.name} has been created successfully`, - }); - return newCategory; - } catch (error) { - console.error('Error creating category:', error); - toast({ - variant: 'error', - title: 'Failed to Create Category', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Update a category - updateCategory: async (id, updates) => { - try { - const category = await db.categories.get(id); - if (!category) { - throw new Error('Category not found'); - } - - const name = (updates.name ?? category.name)?.trim() || ''; - if (!name) { - throw new Error('Category name is required'); - } - if (!/^[A-Za-z0-9]+$/.test(name)) { - throw new Error('Category name must contain only letters and numbers'); - } - if (name.length > get().maxCategoryNameLength) { - throw new Error(`Category name must be ${get().maxCategoryNameLength} characters or fewer`); - } - - const categories = await db.categories - .where('projectId') - .equals(category.projectId) - .toArray(); - const maxDepth = get().maxCategoryDepth; - const newParentId = - updates.parentCategoryId === undefined ? category.parentCategoryId : updates.parentCategoryId; - const newName = updates.name === undefined ? category.name : updates.name; - - const parentDepth = newParentId - ? get().getCategoryDepth(newParentId, categories) - : 0; - const subtreeDepth = get().getMaxSubtreeDepth(category.id, categories); - const totalDepth = parentDepth + subtreeDepth; - - if (!Number.isFinite(parentDepth) || totalDepth > maxDepth) { - throw new Error(`Category depth cannot exceed ${maxDepth} levels.`); - } - const parentRootId = newParentId - ? get().getRootCategoryId(newParentId, categories) - : null; - const rootId = parentRootId || category.id; - const isUnique = get().isNameUniqueInTree(newName, rootId, categories, category.id); - if (!isUnique) { - throw new Error('Category name must be unique within its tree.'); - } - if (!newParentId) { - const existingRoot = categories.find( - (cat) => cat.id !== category.id && !cat.parentCategoryId && cat.name === newName - ); - if (existingRoot) { - throw new Error('Category name must be unique within its tree.'); - } - } - - const updatedCategory = { - ...category, - ...updates, - modifiedAt: new Date().toISOString(), - }; - - await db.categories.update(id, updatedCategory); - await get().loadCategories(category.projectId); - useSyncStore.getState().schedulePush(category.projectId); - toast({ - variant: 'success', - title: 'Category Updated', - description: 'Category has been updated successfully', - }); - return updatedCategory; - } catch (error) { - console.error('Error updating category:', error); - toast({ - variant: 'error', - title: 'Failed to Update Category', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Delete a category - deleteCategory: async (id) => { - try { - const category = await db.categories.get(id); - if (!category) { - throw new Error('Category not found'); - } - - await db.categories.delete(id); - await get().loadCategories(category.projectId); - useSyncStore.getState().schedulePush(category.projectId); - toast({ - variant: 'success', - title: 'Category Deleted', - description: 'Category has been deleted', - }); - } catch (error) { - console.error('Error deleting category:', error); - toast({ - variant: 'error', - title: 'Failed to Delete Category', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Import categories from JSON with full paths - importCategories: async (projectId, categoriesData) => { - try { - const now = new Date().toISOString(); - const existingCategories = await db.categories - .where('projectId') - .equals(projectId) - .toArray(); - const maxDepth = get().maxCategoryDepth; - const rootNameSets = new Map(); - const getRootNameSet = (rootName) => { - if (!rootNameSets.has(rootName)) { - rootNameSets.set(rootName, new Set()); - } - return rootNameSets.get(rootName); - }; - - const existingRoots = existingCategories.filter((cat) => !cat.parentCategoryId); - existingRoots.forEach((root) => { - const rootSet = getRootNameSet(root.name); - rootSet.add(root.name); - existingCategories.forEach((cat) => { - if (get().getRootCategoryId(cat.id, existingCategories) === root.id) { - rootSet.add(cat.name); - } - }); - }); - - // Map to track created categories by full path - const categoryMap = new Map(); - - // Add existing categories to map - existingCategories.forEach((cat) => { - const fullPath = get().buildCategoryPath(cat.id, existingCategories); - categoryMap.set(fullPath, cat.id); - }); - - const createdCategories = []; - - // Process each category from import - for (const catData of categoriesData) { - const fullPath = catData.fullPath || catData.name; - - // Skip if already exists - if (categoryMap.has(fullPath)) { - continue; - } - - // Split path and create hierarchy - const pathParts = fullPath.split('.'); - if (pathParts.length > maxDepth) { - throw new Error(`Category depth cannot exceed ${maxDepth} levels.`); - } - const rootName = pathParts[0]; - const rootSet = getRootNameSet(rootName); - for (let i = 0; i < pathParts.length; i++) { - const name = pathParts[i]; - if (!/^[A-Za-z0-9]+$/.test(name)) { - throw new Error('Category name must contain only letters and numbers'); - } - if (name.length > get().maxCategoryNameLength) { - throw new Error(`Category name must be ${get().maxCategoryNameLength} characters or fewer`); - } - if (rootSet.has(name) && !(i === 0 && name === rootName)) { - throw new Error('Category name must be unique within its tree.'); - } - rootSet.add(name); - } - let currentPath = ''; - let parentId = null; - - for (let i = 0; i < pathParts.length; i++) { - const part = pathParts[i]; - currentPath = currentPath ? `${currentPath}.${part}` : part; - - // Check if this path segment already exists - if (categoryMap.has(currentPath)) { - parentId = categoryMap.get(currentPath); - continue; - } - - // Create new category - const newCategory = { - id: uuidv4(), - name: part, - parentCategoryId: parentId, - projectId, - createdAt: now, - modifiedAt: now, - }; - - await db.categories.add(newCategory); - categoryMap.set(currentPath, newCategory.id); - createdCategories.push(newCategory); - parentId = newCategory.id; - } - } - - await get().loadCategories(projectId); - useSyncStore.getState().schedulePush(projectId); - toast({ - variant: 'success', - title: 'Categories Imported', - description: `${createdCategories.length} categories have been imported`, - }); - return createdCategories; - } catch (error) { - console.error('Error importing categories:', error); - toast({ - variant: 'error', - title: 'Failed to Import Categories', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Build full path for a category - buildCategoryPath: (categoryId, categoriesList) => { - const path = []; - let current = categoriesList.find((c) => c.id === categoryId); - while (current) { - path.unshift(current.name); - current = categoriesList.find((c) => c.id === current.parentCategoryId); - } - return path.join('.'); - }, - - // Export categories to JSON with full paths - exportCategories: async (projectId) => { - try { - const categories = await db.categories - .where('projectId') - .equals(projectId) - .toArray(); - - // Build full paths for each category - return categories.map((category) => ({ - name: category.name, - fullPath: get().buildCategoryPath(category.id, categories), - })); - } catch (error) { - console.error('Error exporting categories:', error); - toast({ - variant: 'error', - title: 'Failed to Export Categories', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, +import { assertUnreferenced, buildCategoryPath, categoryAncestors, getRootCategoryId, prepareCategoryImport, validateCategoryTree, validateEntityName } from '@/lib/domainIntegrity'; +import { changeDomainRecords, findDomainRecord, loadDomainRecords } from './domainStoreSupport'; + +export const useCategoryStore = create((set) => ({ + categories: [], isLoading: false, maxCategoryDepth: 5, maxCategoryNameLength: 16, + isCategoryNameValid: (name) => /^[A-Za-z0-9]{1,16}$/.test(name || ''), + getRootCategoryId, buildCategoryPath, + getCategoryDepth: (id, records) => categoryAncestors(id, records).length, + getMaxSubtreeDepth: (id, records) => Math.max(1, ...records.map((record) => { const chain = categoryAncestors(record.id, records); const index = chain.findIndex((parent) => parent.id === id); return index < 0 ? 0 : index + 1; })), + isNameUniqueInTree: (name, rootId, records, excludeId = null) => !records.some((record) => record.id !== excludeId && record.name === name && getRootCategoryId(record.id, records) === rootId), + loadCategories: (projectId, context) => loadDomainRecords(set, 'categories', projectId, context), + createCategory: async (data) => { + const context = await getRepositoryContext(), id = crypto.randomUUID(), now = new Date().toISOString(); + const category = { ...data, id, name: validateEntityName(data.name, 'Category'), createdAt: now, modifiedAt: now }; + await changeDomainRecords({ context, projectId: data.projectId, set, table: 'categories', message: 'Category Created', achievement: trackFirstCategoryCreated, transform: (snapshot) => { + snapshot.categories.push(category); validateCategoryTree(snapshot.categories, data.projectId); + } }); + return category; + }, + updateCategory: async (id, updates) => { + const { context, record } = await findDomainRecord('categories', id); + let category; + await changeDomainRecords({ context, projectId: record.projectId, set, table: 'categories', message: 'Category Updated', transform: (snapshot) => { + const current = snapshot.categories.find((entry) => entry.id === id); + if (!current) throw new Error('Category was removed. Reload before editing.'); + category = { ...current, ...updates, id, projectId: current.projectId, name: validateEntityName(updates.name ?? current.name, 'Category'), modifiedAt: new Date().toISOString() }; + snapshot.categories = snapshot.categories.map((entry) => entry.id === id ? category : entry); + validateCategoryTree(snapshot.categories, record.projectId); + } }); + return category; + }, + deleteCategory: async (id) => { + const { context, record } = await findDomainRecord('categories', id); + await changeDomainRecords({ context, projectId: record.projectId, set, table: 'categories', message: 'Category Deleted', transform: (snapshot) => { + assertUnreferenced(snapshot, 'categories', record); snapshot.categories = snapshot.categories.filter((entry) => entry.id !== id); + } }); + }, + importCategories: async (projectId, inputs) => { + const context = await getRepositoryContext(); let created; + await changeDomainRecords({ context, projectId, set, table: 'categories', message: 'Categories Imported', transform: (snapshot) => { + const prepared = prepareCategoryImport(projectId, snapshot.categories, inputs); snapshot.categories = prepared.categories; created = prepared.created; + } }); + return created; + }, + exportCategories: async (projectId) => { + const context = await getRepositoryContext(); + const categories = await context.db.categories.where('projectId').equals(projectId).toArray(); + context.assertCurrent(); validateCategoryTree(categories, projectId); + return categories.map((category) => ({ id: category.id, name: category.name, fullPath: buildCategoryPath(category.id, categories) })); + }, })); diff --git a/src/stores/conditionStore.js b/src/stores/conditionStore.js index ee24781..3053f73 100644 --- a/src/stores/conditionStore.js +++ b/src/stores/conditionStore.js @@ -1,175 +1,3 @@ -import { create } from 'zustand'; -import { db } from '@/lib/db'; -import { v4 as uuidv4 } from 'uuid'; -import { toast } from '@/components/ui/toaster'; -import { useSyncStore } from '@/stores/syncStore'; +import { createDefinitionStore } from './createDefinitionStore'; import { trackFirstConditionCreated } from '@/lib/achievements/achievementTracker'; - -export const useConditionStore = create((set, get) => ({ - conditions: [], - isLoading: false, - - loadConditions: async (projectId) => { - set({ isLoading: true }); - try { - const conditions = await db.conditions.where('projectId').equals(projectId).toArray(); - set({ conditions, isLoading: false }); - } catch (error) { - console.error('Failed to load conditions:', error); - toast({ - variant: 'error', - title: 'Failed to Load Conditions', - description: error.message || 'An unexpected error occurred', - }); - set({ isLoading: false }); - } - }, - - createCondition: async (conditionData) => { - try { - const now = new Date().toISOString(); - const id = uuidv4(); - const newCondition = { - id, - ...conditionData, - createdAt: now, - modifiedAt: now, - }; - - await db.conditions.add(newCondition); - try { - await trackFirstConditionCreated(conditionData.projectId); - } catch (error) { - console.warn('[achievements] Failed to track first condition:', error); - } - await get().loadConditions(conditionData.projectId); - useSyncStore.getState().schedulePush(conditionData.projectId); - toast({ - variant: 'success', - title: 'Condition Created', - description: `${conditionData.name} has been created successfully`, - }); - return newCondition; - } catch (error) { - console.error('Error creating condition:', error); - toast({ - variant: 'error', - title: 'Failed to Create Condition', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - updateCondition: async (id, updates) => { - try { - const condition = await db.conditions.get(id); - if (!condition) { - throw new Error('Condition not found'); - } - - const updatedCondition = { - ...condition, - ...updates, - modifiedAt: new Date().toISOString(), - }; - - await db.conditions.update(id, updatedCondition); - await get().loadConditions(condition.projectId); - useSyncStore.getState().schedulePush(condition.projectId); - toast({ - variant: 'success', - title: 'Condition Updated', - description: 'Condition has been updated successfully', - }); - return updatedCondition; - } catch (error) { - console.error('Error updating condition:', error); - toast({ - variant: 'error', - title: 'Failed to Update Condition', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - deleteCondition: async (id) => { - try { - const condition = await db.conditions.get(id); - if (!condition) { - throw new Error('Condition not found'); - } - - await db.conditions.delete(id); - await get().loadConditions(condition.projectId); - useSyncStore.getState().schedulePush(condition.projectId); - toast({ - variant: 'success', - title: 'Condition Deleted', - description: 'Condition has been deleted', - }); - } catch (error) { - console.error('Error deleting condition:', error); - toast({ - variant: 'error', - title: 'Failed to Delete Condition', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - importConditions: async (projectId, conditionsData) => { - try { - const now = new Date().toISOString(); - const conditionsToImport = conditionsData.map((condition) => ({ - ...condition, - id: uuidv4(), - projectId, - createdAt: now, - modifiedAt: now, - })); - - await db.conditions.bulkAdd(conditionsToImport); - await get().loadConditions(projectId); - useSyncStore.getState().schedulePush(projectId); - toast({ - variant: 'success', - title: 'Conditions Imported', - description: `${conditionsToImport.length} conditions have been imported`, - }); - return conditionsToImport; - } catch (error) { - console.error('Error importing conditions:', error); - toast({ - variant: 'error', - title: 'Failed to Import Conditions', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - exportConditions: async (projectId) => { - try { - const conditions = await db.conditions.where('projectId').equals(projectId).toArray(); - return conditions.map((condition) => { - const exported = { ...condition }; - delete exported.id; - delete exported.projectId; - delete exported.createdAt; - delete exported.modifiedAt; - return exported; - }); - } catch (error) { - console.error('Error exporting conditions:', error); - toast({ - variant: 'error', - title: 'Failed to Export Conditions', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, -})); +export const useConditionStore = createDefinitionStore('conditions', 'Condition', trackFirstConditionCreated); diff --git a/src/stores/createDefinitionStore.js b/src/stores/createDefinitionStore.js new file mode 100644 index 0000000..5d9e140 --- /dev/null +++ b/src/stores/createDefinitionStore.js @@ -0,0 +1,49 @@ +import { create } from 'zustand'; +import { getRepositoryContext } from '@/lib/db'; +import { assertCompatibleDefinition, assertUnreferenced, validateDefinition } from '@/lib/domainIntegrity'; +import { changeDomainRecords, findDomainRecord, loadDomainRecords } from './domainStoreSupport'; + +export function createDefinitionStore(table, singular, achievement) { + const plural = `${singular}s`; + return create((set) => ({ + [table]: [], isLoading: false, + [`load${plural}`]: (projectId, context) => loadDomainRecords(set, table, projectId, context), + [`create${singular}`]: async (data) => { + const context = await getRepositoryContext(), now = new Date().toISOString(); + const record = { ...data, id: crypto.randomUUID(), createdAt: now, modifiedAt: now }; + validateDefinition(record); + await changeDomainRecords({ context, projectId: data.projectId, table, set, achievement, message: `${singular} Created`, transform: (snapshot) => { snapshot[table].push(record); } }); + return record; + }, + [`update${singular}`]: async (id, updates) => { + const { context, record } = await findDomainRecord(table, id); let next; + await changeDomainRecords({ context, projectId: record.projectId, table, set, message: `${singular} Updated`, transform: (snapshot) => { + const previous = snapshot[table].find((entry) => entry.id === id); + if (!previous) throw new Error('Definition was removed. Reload before editing.'); + next = { ...previous, ...updates, id, projectId: previous.projectId, modifiedAt: new Date().toISOString() }; + assertCompatibleDefinition(snapshot, table, previous, next); + snapshot[table] = snapshot[table].map((entry) => entry.id === id ? next : entry); + } }); + return next; + }, + [`delete${singular}`]: async (id) => { + const { context, record } = await findDomainRecord(table, id); + await changeDomainRecords({ context, projectId: record.projectId, table, set, message: `${singular} Deleted`, transform: (snapshot) => { + assertUnreferenced(snapshot, table, record); snapshot[table] = snapshot[table].filter((entry) => entry.id !== id); + } }); + }, + [`import${plural}`]: async (projectId, inputs) => { + const context = await getRepositoryContext(), now = new Date().toISOString(); + const records = inputs.map((input) => ({ ...input, id: crypto.randomUUID(), projectId, createdAt: now, modifiedAt: now })); + records.forEach(validateDefinition); + await changeDomainRecords({ context, projectId, table, set, message: `${plural} Imported`, transform: (snapshot) => { snapshot[table].push(...records); } }); + return records; + }, + [`export${plural}`]: async (projectId) => { + const context = await getRepositoryContext(); + const records = await context.db[table].where('projectId').equals(projectId).toArray(); + context.assertCurrent(); + return records.map((record) => { const exported = { ...record }; delete exported.projectId; return exported; }); + }, + })); +} diff --git a/src/stores/decoratorStore.js b/src/stores/decoratorStore.js index 7d7e6ed..625eef5 100644 --- a/src/stores/decoratorStore.js +++ b/src/stores/decoratorStore.js @@ -1,189 +1,3 @@ -import { create } from 'zustand'; -import { db } from '@/lib/db'; -import { v4 as uuidv4 } from 'uuid'; -import { toast } from '@/components/ui/toaster'; -import { useSyncStore } from '@/stores/syncStore'; +import { createDefinitionStore } from './createDefinitionStore'; import { trackFirstDecoratorCreated } from '@/lib/achievements/achievementTracker'; - -export const useDecoratorStore = create((set, get) => ({ - decorators: [], - isLoading: false, - - // Load decorators for a specific project - loadDecorators: async (projectId) => { - set({ isLoading: true }); - try { - const decorators = await db.decorators - .where('projectId') - .equals(projectId) - .toArray(); - set({ decorators, isLoading: false }); - } catch (error) { - console.error('Failed to load decorators:', error); - toast({ - variant: 'error', - title: 'Failed to Load Decorators', - description: error.message || 'An unexpected error occurred', - }); - set({ isLoading: false }); - } - }, - - // Create a new decorator - createDecorator: async (decoratorData) => { - try { - const now = new Date().toISOString(); - const id = uuidv4(); - const newDecorator = { - id, - ...decoratorData, - createdAt: now, - modifiedAt: now, - }; - - await db.decorators.add(newDecorator); - try { - await trackFirstDecoratorCreated(decoratorData.projectId); - } catch (error) { - console.warn('[achievements] Failed to track first decorator:', error); - } - await get().loadDecorators(decoratorData.projectId); - useSyncStore.getState().schedulePush(decoratorData.projectId); - toast({ - variant: 'success', - title: 'Decorator Created', - description: `${decoratorData.name} has been created successfully`, - }); - return newDecorator; - } catch (error) { - console.error('Error creating decorator:', error); - toast({ - variant: 'error', - title: 'Failed to Create Decorator', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Update a decorator - updateDecorator: async (id, updates) => { - try { - const decorator = await db.decorators.get(id); - if (!decorator) { - throw new Error('Decorator not found'); - } - - const updatedDecorator = { - ...decorator, - ...updates, - modifiedAt: new Date().toISOString(), - }; - - await db.decorators.update(id, updatedDecorator); - await get().loadDecorators(decorator.projectId); - useSyncStore.getState().schedulePush(decorator.projectId); - toast({ - variant: 'success', - title: 'Decorator Updated', - description: 'Decorator has been updated successfully', - }); - return updatedDecorator; - } catch (error) { - console.error('Error updating decorator:', error); - toast({ - variant: 'error', - title: 'Failed to Update Decorator', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Delete a decorator - deleteDecorator: async (id) => { - try { - const decorator = await db.decorators.get(id); - if (!decorator) { - throw new Error('Decorator not found'); - } - - await db.decorators.delete(id); - await get().loadDecorators(decorator.projectId); - useSyncStore.getState().schedulePush(decorator.projectId); - toast({ - variant: 'success', - title: 'Decorator Deleted', - description: 'Decorator has been deleted', - }); - } catch (error) { - console.error('Error deleting decorator:', error); - toast({ - variant: 'error', - title: 'Failed to Delete Decorator', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Import decorators from JSON - importDecorators: async (projectId, decoratorsData) => { - try { - const now = new Date().toISOString(); - const decoratorsToImport = decoratorsData.map((d) => ({ - ...d, - id: uuidv4(), - projectId, - createdAt: now, - modifiedAt: now, - })); - - await db.decorators.bulkAdd(decoratorsToImport); - await get().loadDecorators(projectId); - useSyncStore.getState().schedulePush(projectId); - toast({ - variant: 'success', - title: 'Decorators Imported', - description: `${decoratorsToImport.length} decorators have been imported`, - }); - return decoratorsToImport; - } catch (error) { - console.error('Error importing decorators:', error); - toast({ - variant: 'error', - title: 'Failed to Import Decorators', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Export decorators to JSON - exportDecorators: async (projectId) => { - try { - const decorators = await db.decorators - .where('projectId') - .equals(projectId) - .toArray(); - - // Remove projectId and timestamps for clean export - return decorators.map((decorator) => { - const exported = { ...decorator }; - delete exported.id; - delete exported.projectId; - delete exported.createdAt; - delete exported.modifiedAt; - return exported; - }); - } catch (error) { - console.error('Error exporting decorators:', error); - toast({ - variant: 'error', - title: 'Failed to Export Decorators', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, -})); +export const useDecoratorStore = createDefinitionStore('decorators', 'Decorator', trackFirstDecoratorCreated); diff --git a/src/stores/dialogueStore.js b/src/stores/dialogueStore.js index 993d4c3..76895bb 100644 --- a/src/stores/dialogueStore.js +++ b/src/stores/dialogueStore.js @@ -1,30 +1,25 @@ +import { describeError, errorToast } from '@/lib/errorPresentation'; +import { exportProjectArchive, prepareArchiveImport } from '@/lib/persistence/projectArchive'; +import { buildProjectSnapshot } from '@/lib/sync/snapshot'; +import { commitPreparedProject, mutateProject, readProjectState } from '@/lib/persistence/projectRepository'; +import { assertUnreferenced } from '@/lib/domainIntegrity'; +import { changeDomainRecords, findDomainRecord } from './domainStoreSupport'; import { create } from 'zustand'; import { v4 as uuidv4 } from 'uuid'; -import { db } from '@/lib/db'; +import { db, getRepositoryContext } from '@/lib/db'; import { toast } from '@/components/ui/toaster'; import { openContainingFolder, saveExportBlob } from '@/lib/export/exportFile'; import { useSyncStore } from '@/stores/syncStore'; import { DEFAULT_LOCALE, - buildStringTableV2Payload, - ensureDialogueLocalizationSlug, + allocateDialogueLocalizationSlug, filterLocalizedEntriesByDialogue, materializeLocalizedNodes, normalizeLocaleTag, normalizeProjectLocalizationConfig, - parseImportedStringTableData, prepareLocalizedNodesAndEntries, - remapLocalizedEntriesForImportedDialogue, validateLocalizedEntriesForDialogue, } from '@/lib/localization/stringTable'; -import { - blobToStoredParticipantThumbnail, - buildParticipantImageId, - storedParticipantThumbnailToBlob, -} from '@/lib/participantThumbnails'; -import { sanitizeAudioFileName } from '@/lib/assetNaming'; -import { resolveRowAudioImportSelection } from '@/lib/dialogueImportAudio'; - const normalizeDialogueRow = (row = {}) => ({ ...row, id: row.id || uuidv4(), @@ -63,19 +58,11 @@ function getProjectLocalizationState(project) { }; } -async function loadDialogueLocalizedEntries(projectId, dialogueId) { - const allEntries = await db.localizedStrings.where('projectId').equals(projectId).toArray(); +async function loadDialogueLocalizedEntries(projectId, dialogueId, database = db) { + const allEntries = await database.localizedStrings.where('projectId').equals(projectId).toArray(); return filterLocalizedEntriesByDialogue(allEntries, dialogueId); } -function stripLocalizedTextFromRows(rows = []) { - return normalizeDialogueRows(rows).map((row) => { - const next = { ...row }; - delete next.text; - return next; - }); -} - function buildPersistedNodesWithoutLocalizedText(nodes = [], dialogueId = '') { return (nodes || []).map((node) => ({ ...node, @@ -97,26 +84,19 @@ function summarizeLocalizationValidationErrors(errors = [], limit = 3) { return remainder > 0 ? `${parts.join(', ')} (+${remainder} more)` : parts.join(', '); } -async function ensureDialogueLocalizationMetadata(dialogue = null) { - if (!dialogue?.id) return dialogue; - const nextSlug = ensureDialogueLocalizationSlug(dialogue); - const nextVersion = 2; - const shouldUpdate = - String(dialogue.localizationSlug || '').trim() !== nextSlug || - Number(dialogue.localizationVersion || 0) < nextVersion; - if (!shouldUpdate) return dialogue; - await db.dialogues.update(dialogue.id, { - localizationSlug: nextSlug, - localizationVersion: nextVersion, - modifiedAt: new Date().toISOString(), - }); - return { - ...dialogue, - localizationSlug: nextSlug, - localizationVersion: nextVersion, - }; +function assertLocalizedDialogue({ dialogue, nodes, entries, defaultLocale, projectEntries }) { + const validation = validateLocalizedEntriesForDialogue({ nodes, entries, defaultLocale, + projectId: dialogue.projectId, dialogueId: dialogue.id, dialogueSlug: dialogue.localizationSlug, projectEntries }); + if (!validation.valid) { + const error = new Error(`Localization requires repair: ${summarizeLocalizationValidationErrors(validation.errors)}`); + error.code = 'LOCALIZATION_REPAIR_REQUIRED'; + error.diagnostics = validation.errors; + throw error; + } } +const loadRequests = {}; + /** * Dialogue Store * Manages dialogues, nodes, and edges state @@ -133,16 +113,19 @@ export const useDialogueStore = create((set, get) => ({ * Load dialogues for a project (or all dialogues if no projectId) */ loadDialogues: async (projectId) => { + const request = loadRequests.loadDialogues = (loadRequests.loadDialogues || 0) + 1; set({ isLoading: true, error: null }); try { + const context = await getRepositoryContext(); + const database = context.db; const dialogues = projectId - ? await db.dialogues.where('projectId').equals(projectId).toArray() - : await db.dialogues.toArray(); + ? await database.dialogues.where('projectId').equals(projectId).toArray() + : await database.dialogues.toArray(); // Load node counts for each dialogue const dialoguesWithCounts = await Promise.all( dialogues.map(async (dialogue) => { - const nodeCount = await db.nodes + const nodeCount = await database.nodes .where('dialogueId') .equals(dialogue.id) .count(); @@ -150,15 +133,15 @@ export const useDialogueStore = create((set, get) => ({ }) ); + context.assertCurrent(); + if (request !== loadRequests.loadDialogues) return; set({ dialogues: dialoguesWithCounts, isLoading: false }); } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; + if (request !== loadRequests.loadDialogues) return; console.error('Error loading dialogues:', error); - toast({ - variant: 'error', - title: 'Failed to Load Dialogues', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'load')); + set({ error: describeError(error), isLoading: false }); } }, @@ -168,21 +151,15 @@ export const useDialogueStore = create((set, get) => ({ createDialogue: async (dialogueData) => { set({ isLoading: true, error: null }); try { - const now = new Date().toISOString(); - const id = uuidv4(); - const localizationSlug = ensureDialogueLocalizationSlug({ - id, - name: dialogueData?.name, - }); - const newDialogue = { - id, - ...dialogueData, - localizationSlug, - localizationVersion: 2, - createdAt: now, - modifiedAt: now, - }; - await db.dialogues.add(newDialogue); + const context = await getRepositoryContext(); + const now = new Date().toISOString(); + const id = uuidv4(); + let newDialogue; + await mutateProject(dialogueData.projectId, { context, transform: (snapshot) => { + newDialogue = { ...dialogueData, id, localizationSlug: allocateDialogueLocalizationSlug({ ...dialogueData, id }, snapshot.dialogues), localizationVersion: 2, createdAt: now, modifiedAt: now }; + snapshot.dialogues.push(newDialogue); + } }); + context.assertCurrent(); await get().loadDialogues(dialogueData.projectId); useSyncStore.getState().schedulePush(dialogueData.projectId); toast({ @@ -192,13 +169,10 @@ export const useDialogueStore = create((set, get) => ({ }); return newDialogue; } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; console.error('Error creating dialogue:', error); - toast({ - variant: 'error', - title: 'Failed to Create Dialogue', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'create')); + set({ error: describeError(error), isLoading: false }); throw error; } }, @@ -209,11 +183,14 @@ export const useDialogueStore = create((set, get) => ({ updateDialogue: async (id, updates) => { set({ isLoading: true, error: null }); try { - await db.dialogues.update(id, { - ...updates, - modifiedAt: new Date().toISOString(), - }); - const dialogue = await db.dialogues.get(id); + const context = await getRepositoryContext(), dialogue = await context.db.dialogues.get(id); + context.assertCurrent(); + if (!dialogue) throw new Error('Dialogue not found'); + await mutateProject(dialogue.projectId, { context, transform: (snapshot) => { + const original = snapshot.dialogues.find((row) => row.id === id); + Object.assign(original, updates, { id, projectId: dialogue.projectId, modifiedAt: new Date().toISOString() }); + } }); + context.assertCurrent(); await get().loadDialogues(dialogue.projectId); useSyncStore.getState().schedulePush(dialogue.projectId); toast({ @@ -222,13 +199,10 @@ export const useDialogueStore = create((set, get) => ({ description: 'Dialogue has been updated successfully', }); } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; console.error('Error updating dialogue:', error); - toast({ - variant: 'error', - title: 'Failed to Update Dialogue', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'update')); + set({ error: describeError(error), isLoading: false }); throw error; } }, @@ -237,56 +211,30 @@ export const useDialogueStore = create((set, get) => ({ * Delete a dialogue and all its nodes/edges */ deleteDialogue: async (id) => { - set({ isLoading: true, error: null }); - try { - const dialogue = await db.dialogues.get(id); - if (!dialogue) { - throw new Error('Dialogue not found'); - } - const syncedProviders = Array.from( - new Set( - (await db.syncProjects.where('projectId').equals(dialogue.projectId).toArray()) - .map((entry) => String(entry?.provider || '').trim()) - .filter(Boolean) - ) - ); - await db.transaction('rw', [db.dialogues, db.nodes, db.edges, db.localizedStrings], async () => { - await db.dialogues.delete(id); - await db.nodes.where('dialogueId').equals(id).delete(); - await db.edges.where('dialogueId').equals(id).delete(); - await db.localizedStrings.where('dialogueId').equals(id).delete(); - }); - await get().loadDialogues(dialogue.projectId); - await useSyncStore.getState().scheduleDialogueDeletion(id, dialogue.projectId, { - providers: syncedProviders, - }); - useSyncStore.getState().schedulePush(dialogue.projectId); - toast({ - variant: 'success', - title: 'Dialogue Deleted', - description: 'Dialogue and all nodes have been deleted', - }); - } catch (error) { - console.error('Error deleting dialogue:', error); - toast({ - variant: 'error', - title: 'Failed to Delete Dialogue', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); - throw error; - } + const { context, record } = await findDomainRecord('dialogues', id); + await changeDomainRecords({ context, projectId: record.projectId, set, table: 'dialogues', message: 'Dialogue Deleted', transform: (snapshot) => { + assertUnreferenced(snapshot, 'dialogues', record); + snapshot.dialogues = snapshot.dialogues.filter((entry) => entry.id !== id); + for (const table of ['nodes', 'edges', 'localizedStrings']) snapshot[table] = snapshot[table].filter((entry) => entry.dialogueId !== id); + } }); + context.assertCurrent(); + if (get().currentDialogue?.id === id) set({ currentDialogue: null, nodes: [], edges: [] }); }, /** * Set current active dialogue and load its nodes/edges */ setCurrentDialogue: async (id) => { + const request = loadRequests.setCurrentDialogue = (loadRequests.setCurrentDialogue || 0) + 1; set({ isLoading: true, error: null }); try { - const dialogue = await db.dialogues.get(id); - const nodes = await db.nodes.where('dialogueId').equals(id).toArray(); - const edges = await db.edges.where('dialogueId').equals(id).toArray(); + const context = await getRepositoryContext(); + const database = context.db; + const dialogue = await database.dialogues.get(id); + const nodes = await database.nodes.where('dialogueId').equals(id).toArray(); + const edges = await database.edges.where('dialogueId').equals(id).toArray(); + context.assertCurrent(); + if (request !== loadRequests.setCurrentDialogue) return; set({ currentDialogue: dialogue, nodes, @@ -294,13 +242,11 @@ export const useDialogueStore = create((set, get) => ({ isLoading: false }); } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; + if (request !== loadRequests.setCurrentDialogue) return; console.error('Error setting current dialogue:', error); - toast({ - variant: 'error', - title: 'Failed to Load Dialogue', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'load')); + set({ error: describeError(error), isLoading: false }); throw error; } }, @@ -309,14 +255,16 @@ export const useDialogueStore = create((set, get) => ({ * Update nodes for current dialogue */ updateNodes: async (dialogueId, nodes, options = {}) => { - const dialogue = await db.dialogues.get(dialogueId); - const edges = await db.edges.where('dialogueId').equals(dialogueId).toArray(); + const context = await getRepositoryContext(); + const dialogue = await context.db.dialogues.get(dialogueId); + const edges = await context.db.edges.where('dialogueId').equals(dialogueId).toArray(); + context.assertCurrent(); return get().saveDialogueGraph( dialogueId, nodes, edges, dialogue?.viewport || { x: 0, y: 0, zoom: 1 }, - options + { ...options, context } ); }, @@ -326,21 +274,17 @@ export const useDialogueStore = create((set, get) => ({ updateEdges: async (dialogueId, edges) => { set({ isLoading: true, error: null }); try { - await db.transaction('rw', db.edges, async () => { - await db.edges.where('dialogueId').equals(dialogueId).delete(); - await db.edges.bulkAdd( - edges.map(edge => ({ ...edge, dialogueId })) - ); - }); + const context = await getRepositoryContext(), dialogue = await context.db.dialogues.get(dialogueId); + context.assertCurrent(); + if (!dialogue) throw new Error('Dialogue not found'); + await mutateProject(dialogue.projectId, { context, transform: (snapshot) => { snapshot.edges = snapshot.edges.filter((edge) => edge.dialogueId !== dialogueId).concat(edges.map((edge) => ({ ...edge, dialogueId }))); } }); + context.assertCurrent(); set({ edges, isLoading: false }); } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; console.error('Error updating edges:', error); - toast({ - variant: 'error', - title: 'Failed to Update Edges', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'update')); + set({ error: describeError(error), isLoading: false }); throw error; } }, @@ -349,119 +293,42 @@ export const useDialogueStore = create((set, get) => ({ * Save both nodes and edges for a dialogue */ saveDialogueGraph: async (dialogueId, nodes, edges, viewport, options = {}) => { - set({ isLoading: true, error: null }); - try { - const { prepareAudioForStorage } = await import('@/lib/audioUtils'); - const dialogue = await ensureDialogueLocalizationMetadata(await db.dialogues.get(dialogueId)); - const project = dialogue?.projectId ? await db.projects.get(dialogue.projectId) : null; - const localizationState = getProjectLocalizationState(project); - const activeLocale = normalizeLocaleTag( - options?.activeLocale, - localizationState.defaultLocale - ); - - if (!dialogue?.projectId) { - throw new Error('Dialogue has no project scope for localization.'); - } - - const existingEntries = await loadDialogueLocalizedEntries(dialogue.projectId, dialogueId); - const prepared = prepareLocalizedNodesAndEntries({ - projectId: dialogue.projectId, - dialogueId, - dialogueSlug: dialogue.localizationSlug, - nodes, - locale: activeLocale, - existingEntries, - }); - const sourceNodesForPersistence = prepared.nodes; - const localizedEntriesToUpsert = prepared.entries; - - if (prepared.diagnostics.some((item) => item.type === 'duplicate_key')) { - throw new Error('Localization key collision detected. Please rename duplicated items.'); - } - const validation = validateLocalizedEntriesForDialogue({ - nodes: sourceNodesForPersistence, - entries: localizedEntriesToUpsert, - defaultLocale: localizationState.defaultLocale, - }); - if (!validation.valid) { - console.error('[localization] Validation errors', validation.errors); - throw new Error( - `Localization integrity validation failed for this dialogue: ${summarizeLocalizationValidationErrors( - validation.errors - )}` - ); - } - - // Convert audio blobs to base64 for storage - const processedNodes = await Promise.all( - sourceNodesForPersistence.map(async (node) => { - if (node.data?.dialogueRows) { - const processedRows = await Promise.all( - node.data.dialogueRows.map(async (row) => { - if (row.audioFile?.blob) { - const storedAudio = await prepareAudioForStorage(row.audioFile); - return { ...row, audioFile: storedAudio, text: undefined }; - } - return row; - }) - ); - return { - ...node, - data: { - ...node.data, - dialogueRows: stripLocalizedTextFromRows(processedRows), - }, - }; - } - return node; - }) - ); - - await db.transaction('rw', [db.nodes, db.edges, db.dialogues, db.localizedStrings], async () => { - await db.localizedStrings.where('dialogueId').equals(dialogueId).delete(); - if (localizedEntriesToUpsert.length > 0) { - await db.localizedStrings.bulkPut(localizedEntriesToUpsert); - } - - // Delete existing nodes and edges - await db.nodes.where('dialogueId').equals(dialogueId).delete(); - await db.edges.where('dialogueId').equals(dialogueId).delete(); - - // Add new nodes and edges with processed audio. - const persistedNodes = buildPersistedNodesWithoutLocalizedText( - processedNodes, - dialogueId - ); - if (persistedNodes.length > 0) { - await db.nodes.bulkAdd(persistedNodes); - } - if (edges.length > 0) { - await db.edges.bulkAdd( - edges.map(edge => ({ ...edge, dialogueId })) - ); - } - - // Update dialogue modified time and viewport - await db.dialogues.update(dialogueId, { - modifiedAt: new Date().toISOString(), - viewport: viewport || { x: 0, y: 0, zoom: 1 }, - localizationSlug: dialogue.localizationSlug, - localizationVersion: 2, - }); - }); + set({ isLoading: true, error: null }); + try { + const context = options.context || await getRepositoryContext(); + context.assertCurrent(); + const database = context.db; + const { prepareAudioForStorage } = await import('@/lib/audioUtils'); + // Binary conversion happens before the transaction; IDB must not wait on FileReader. + const processedNodes = await Promise.all(nodes.map(async (node) => { + if (!node.data?.dialogueRows) return node; + const rows = await Promise.all(node.data.dialogueRows.map(async (row) => row.audioFile?.blob ? { ...row, audioFile: await prepareAudioForStorage(row.audioFile) } : row)); + return { ...node, data: { ...node.data, dialogueRows: rows } }; + })); + context.assertCurrent(); + const original = await database.dialogues.get(dialogueId); + if (!original?.projectId) throw new Error('Dialogue not found'); + const projectId = original.projectId; + await mutateProject(projectId, { context, transform: (snapshot) => { + const dialogue = snapshot.dialogues.find((row) => row.id === dialogueId); + dialogue.localizationSlug = allocateDialogueLocalizationSlug(dialogue, snapshot.dialogues); + const { defaultLocale } = getProjectLocalizationState(snapshot.project); + const projectEntries = snapshot.localizedStrings, existingEntries = projectEntries.filter((row) => row.dialogueId === dialogueId); + const prepared = prepareLocalizedNodesAndEntries({ projectId, dialogueId, dialogueSlug: dialogue.localizationSlug, nodes: processedNodes, locale: normalizeLocaleTag(options.activeLocale, defaultLocale), existingEntries }); + assertLocalizedDialogue({ dialogue, nodes: prepared.nodes, entries: prepared.entries, defaultLocale, projectEntries }); + snapshot.localizedStrings = projectEntries.filter((entry) => entry.dialogueId !== dialogueId).concat(prepared.entries); + snapshot.nodes = snapshot.nodes.filter((node) => node.dialogueId !== dialogueId).concat(buildPersistedNodesWithoutLocalizedText(prepared.nodes, dialogueId)); + snapshot.edges = snapshot.edges.filter((edge) => edge.dialogueId !== dialogueId).concat(edges.map((edge) => ({ ...edge, dialogueId }))); + Object.assign(dialogue, { modifiedAt: new Date().toISOString(), viewport: viewport || dialogue.viewport || { x: 0, y: 0, zoom: 1 }, localizationVersion: 2 }); + } }); + context.assertCurrent(); set({ nodes, edges, isLoading: false }); - if (dialogue?.projectId) { - useSyncStore.getState().schedulePush(dialogue.projectId); - } + useSyncStore.getState().schedulePush(projectId); } catch (error) { - console.error('Error saving dialogue graph:', error); - toast({ - variant: 'error', - title: 'Failed to Save Dialogue', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + if (error.code !== 'STALE_PROFILE') { + toast(errorToast(error, 'save')); + set({ error: describeError(error), isLoading: false }); + } throw error; } }, @@ -470,129 +337,57 @@ export const useDialogueStore = create((set, get) => ({ * Load dialogue graph (nodes and edges) */ loadDialogueGraph: async (dialogueId, options = {}) => { - set({ isLoading: true, error: null }); - try { - const { restoreAudioFromStorage } = await import('@/lib/audioUtils'); - - const loadedNodes = await db.nodes.where('dialogueId').equals(dialogueId).toArray(); - const edges = await db.edges.where('dialogueId').equals(dialogueId).toArray(); - const dialogue = await ensureDialogueLocalizationMetadata(await db.dialogues.get(dialogueId)); - const project = dialogue?.projectId ? await db.projects.get(dialogue.projectId) : null; - const viewport = dialogue?.viewport || { x: 0, y: 0, zoom: 1 }; - const localizationState = getProjectLocalizationState(project); - const activeLocale = normalizeLocaleTag( - options?.activeLocale, - localizationState.defaultLocale - ); - const defaultLocale = localizationState.defaultLocale; - let entries = dialogue?.projectId - ? await loadDialogueLocalizedEntries(dialogue.projectId, dialogueId) - : []; - let didPersistMigration = false; - - // Convert base64 back to blobs for audio playback and export - let nodes = loadedNodes.map((node) => { - if (node.data?.dialogueRows) { - const restoredRows = normalizeDialogueRows(node.data.dialogueRows).map((row) => { - if (row.audioFile?.base64) { - const restoredAudio = restoreAudioFromStorage(row.audioFile); - return { ...row, audioFile: restoredAudio }; - } - return row; - }); - return { ...node, data: { ...node.data, dialogueRows: restoredRows } }; - } - return node; - }); - - nodes = materializeLocalizedNodes({ - nodes, - dialogueId, - dialogueSlug: dialogue?.localizationSlug, - locale: activeLocale, - defaultLocale, - stringEntries: entries, - }); - - const hasMissingRefs = nodes.some((node) => { - const nodeData = node?.data || {}; - if (Object.prototype.hasOwnProperty.call(nodeData, 'displayName') && !nodeData.displayNameKey) { - return true; - } - if ( - Object.prototype.hasOwnProperty.call(nodeData, 'selectionTitle') && - !nodeData.selectionTitleKey - ) { - return true; - } - return Array.isArray(nodeData.dialogueRows) - ? nodeData.dialogueRows.some((row) => !row?.textKey) - : false; - }); - - if (dialogue?.projectId && (Number(dialogue.localizationVersion || 0) < 2 || hasMissingRefs)) { - const migrated = prepareLocalizedNodesAndEntries({ - projectId: dialogue.projectId, - dialogueId, - dialogueSlug: dialogue.localizationSlug, - nodes, - locale: defaultLocale, - existingEntries: entries, - }); - if (migrated.entries.length > 0) { - const validation = validateLocalizedEntriesForDialogue({ - nodes: migrated.nodes, - entries: migrated.entries, - defaultLocale, - }); - if (validation.valid) { - await db.transaction('rw', [db.nodes, db.localizedStrings, db.dialogues], async () => { - await db.nodes.where('dialogueId').equals(dialogueId).delete(); - const migratedPersistedNodes = buildPersistedNodesWithoutLocalizedText( - migrated.nodes, - dialogueId - ); - if (migratedPersistedNodes.length > 0) { - await db.nodes.bulkAdd(migratedPersistedNodes); - } - await db.localizedStrings.where('dialogueId').equals(dialogueId).delete(); - await db.localizedStrings.bulkPut(migrated.entries); - await db.dialogues.update(dialogueId, { - localizationSlug: dialogue.localizationSlug, - localizationVersion: 2, - modifiedAt: new Date().toISOString(), - }); - }); - didPersistMigration = true; - entries = migrated.entries; - nodes = materializeLocalizedNodes({ - nodes: migrated.nodes, - dialogueId, - dialogueSlug: dialogue.localizationSlug, - locale: activeLocale, - defaultLocale, - stringEntries: entries, - }); - } else { - console.error('[localization] Migration validation failed', validation.errors); - } - } - } - - if (didPersistMigration && dialogue?.projectId) { - useSyncStore.getState().schedulePush(dialogue.projectId); - } - - set({ nodes, edges, isLoading: false }); - return { nodes, edges, viewport }; + const request = loadRequests.loadDialogueGraph = (loadRequests.loadDialogueGraph || 0) + 1; + set({ isLoading: true, error: null }); + let capturedContext; + try { + const context = await getRepositoryContext(); + capturedContext = context; + const database = context.db; + const { restoreAudioFromStorage } = await import('@/lib/audioUtils'); + context.assertCurrent(); + const initial = await database.dialogues.get(dialogueId); + if (!initial?.projectId) throw new Error('Dialogue not found'); + let state = await readProjectState(initial.projectId, context), migrated = false; + const needsMigration = (snapshot) => { + const dialogue = snapshot.dialogues.find((row) => row.id === dialogueId); + const slug = allocateDialogueLocalizationSlug(dialogue, snapshot.dialogues); + const validation = validateLocalizedEntriesForDialogue({ nodes: snapshot.nodes.filter((node) => node.dialogueId === dialogueId), entries: snapshot.localizedStrings.filter((entry) => entry.dialogueId === dialogueId), defaultLocale: getProjectLocalizationState(snapshot.project).defaultLocale, projectId: initial.projectId, dialogueId, dialogueSlug: slug, projectEntries: snapshot.localizedStrings }); + return Number(dialogue.localizationVersion || 0) < 2 || dialogue.localizationSlug !== slug || !validation.valid; + }; + if (needsMigration(state.snapshot)) { + const result = await mutateProject(initial.projectId, { context, transform: (snapshot) => { + const dialogue = snapshot.dialogues.find((row) => row.id === dialogueId); + dialogue.localizationSlug = allocateDialogueLocalizationSlug(dialogue, snapshot.dialogues); + const defaultLocale = getProjectLocalizationState(snapshot.project).defaultLocale; + const prepared = prepareLocalizedNodesAndEntries({ projectId: initial.projectId, dialogueId, dialogueSlug: dialogue.localizationSlug, nodes: snapshot.nodes.filter((node) => node.dialogueId === dialogueId), locale: defaultLocale, existingEntries: snapshot.localizedStrings.filter((entry) => entry.dialogueId === dialogueId) }); + assertLocalizedDialogue({ dialogue, nodes: prepared.nodes, entries: prepared.entries, defaultLocale, projectEntries: snapshot.localizedStrings }); + snapshot.nodes = snapshot.nodes.filter((node) => node.dialogueId !== dialogueId).concat(buildPersistedNodesWithoutLocalizedText(prepared.nodes, dialogueId)); + snapshot.localizedStrings = snapshot.localizedStrings.filter((entry) => entry.dialogueId !== dialogueId).concat(prepared.entries); + dialogue.localizationVersion = 2; + } }); + state = result; migrated = true; + } + const loaded = { nodes: state.snapshot.nodes.filter((node) => node.dialogueId === dialogueId), edges: state.snapshot.edges.filter((edge) => edge.dialogueId === dialogueId), entries: state.snapshot.localizedStrings.filter((entry) => entry.dialogueId === dialogueId), dialogue: state.snapshot.dialogues.find((row) => row.id === dialogueId), defaultLocale: getProjectLocalizationState(state.snapshot.project).defaultLocale, migrated }; + context.assertCurrent(); + let nodes = materializeLocalizedNodes({ nodes: loaded.nodes, dialogueId, dialogueSlug: loaded.dialogue.localizationSlug, locale: normalizeLocaleTag(options.activeLocale, loaded.defaultLocale), defaultLocale: loaded.defaultLocale, stringEntries: loaded.entries }); + nodes = nodes.map((node) => !node.data?.dialogueRows ? node : { ...node, data: { ...node.data, dialogueRows: normalizeDialogueRows(node.data.dialogueRows).map((row) => row.audioFile?.base64 ? { ...row, audioFile: restoreAudioFromStorage(row.audioFile) } : row) } }); + context.assertCurrent(); + if (request !== loadRequests.loadDialogueGraph) return { nodes, edges: loaded.edges, viewport: loaded.dialogue.viewport }; + if (loaded.migrated) useSyncStore.getState().schedulePush(loaded.dialogue.projectId); + set({ nodes, edges: loaded.edges, isLoading: false }); + return { nodes, edges: loaded.edges, viewport: loaded.dialogue.viewport || { x: 0, y: 0, zoom: 1 } }; } catch (error) { - console.error('Error loading dialogue graph:', error); - toast({ - variant: 'error', - title: 'Failed to Load Dialogue Graph', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + if (error.code === 'LOCALIZATION_REPAIR_REQUIRED' && capturedContext && !capturedContext.signal.aborted) { + const database = capturedContext.db; + const originalDialogue = await database.dialogues.get(dialogueId); + capturedContext.assertCurrent(); + await database.recoveryRecords.put({ id: `localization:${dialogueId}`, projectId: originalDialogue?.projectId, table: 'localizedStrings', code: error.code, status: 'unresolved', path: `dialogues.${dialogueId}`, message: error.message, diagnostics: error.diagnostics, original: originalDialogue }); + } + if (error.code !== 'STALE_PROFILE') { + toast(errorToast(error, 'load')); + set({ error: describeError(error), isLoading: false }); + } throw error; } }, @@ -604,23 +399,22 @@ export const useDialogueStore = create((set, get) => ({ try { const { restoreAudioFromStorage } = await import('@/lib/audioUtils'); - const loadedNodes = await db.nodes.where('dialogueId').equals(dialogueId).toArray(); - const edges = await db.edges.where('dialogueId').equals(dialogueId).toArray(); - const dialogue = await db.dialogues.get(dialogueId); - if (!dialogue) { - return { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }; - } - - const project = dialogue.projectId ? await db.projects.get(dialogue.projectId) : null; - const localizationState = getProjectLocalizationState(project); - const activeLocale = normalizeLocaleTag( - options?.activeLocale, - localizationState.defaultLocale - ); - const defaultLocale = localizationState.defaultLocale; - const entries = dialogue.projectId - ? await loadDialogueLocalizedEntries(dialogue.projectId, dialogueId) - : []; + const context = await getRepositoryContext(); + const database = context.db; + const loaded = await database.transaction('r', [database.nodes, database.edges, database.dialogues, database.projects, database.localizedStrings], async () => { + const dialogue = await database.dialogues.get(dialogueId); + if (!dialogue) return null; + const loadedNodes = await database.nodes.where('dialogueId').equals(dialogueId).toArray(); + const edges = await database.edges.where('dialogueId').equals(dialogueId).toArray(); + const project = await database.projects.get(dialogue.projectId); + const entries = await loadDialogueLocalizedEntries(dialogue.projectId, dialogueId, database); + return { dialogue, loadedNodes, edges, project, entries }; + }); + context.assertCurrent(); + if (!loaded) return { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }; + const { dialogue, loadedNodes, edges, project, entries } = loaded; + const { defaultLocale } = getProjectLocalizationState(project); + const activeLocale = normalizeLocaleTag(options.activeLocale, defaultLocale); let nodes = loadedNodes.map((node) => { if (!node.data?.dialogueRows) return node; @@ -674,14 +468,17 @@ export const useDialogueStore = create((set, get) => ({ */ exportDialogue: async (dialogueId) => { try { + const context = await getRepositoryContext(); + const database = context.db; // Get dialogue name for the file - const dialogue = await db.dialogues.get(dialogueId); + const dialogue = await database.dialogues.get(dialogueId); if (!dialogue) { throw new Error('Dialogue not found'); } // Use the shared blob export function - const blob = await get().exportDialogueAsBlob(dialogueId); + const blob = await get().exportDialogueAsBlob(dialogueId, { context }); + context.assertCurrent(); const defaultFileName = `${dialogue.name}.mnteadlg`; const saveResult = await saveExportBlob({ @@ -689,15 +486,17 @@ export const useDialogueStore = create((set, get) => ({ defaultFileName, filters: [{ name: 'Dialogue Export', extensions: ['mnteadlg'] }], }); + context.assertCurrent(); if (saveResult.canceled) { return; } if (saveResult.filePath) { - await db.dialogues.update(dialogueId, { + await database.dialogues.update(dialogueId, { lastExportPath: saveResult.filePath, }); - set((state) => ({ + context.assertCurrent(); + set((state) => ({ dialogues: state.dialogues.map((entry) => entry.id === dialogueId ? { ...entry, lastExportPath: saveResult.filePath } @@ -725,866 +524,38 @@ export const useDialogueStore = create((set, get) => ({ duration: saveResult.filePath ? 8000 : 3000, }); } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; console.error('Error exporting dialogue:', error); - toast({ - variant: 'error', - title: 'Failed to Export Dialogue', - description: error.message || 'An unexpected error occurred', - }); + toast(errorToast(error, 'export')); throw error; } }, // Export dialogue as Blob (for direct export and nested project exports) - exportDialogueAsBlob: async (dialogueId, options = {}) => { - try { - console.log(`[exportDialogueAsBlob] Starting export for dialogue: ${dialogueId}`); - const JSZip = (await import('jszip')).default; - - const dialogue = await ensureDialogueLocalizationMetadata(await db.dialogues.get(dialogueId)); - if (!dialogue) { - throw new Error(`Dialogue not found: ${dialogueId}`); - } - console.log(`[exportDialogueAsBlob] Found dialogue: ${dialogue.name}`); - - const project = dialogue.projectId ? await db.projects.get(dialogue.projectId) : null; - const localizationState = getProjectLocalizationState(project); - const loadedNodes = await db.nodes.where('dialogueId').equals(dialogueId).toArray(); - const edges = await db.edges.where('dialogueId').equals(dialogueId).toArray(); - const stringTableEntries = dialogue.projectId - ? await loadDialogueLocalizedEntries(dialogue.projectId, dialogueId) - : []; - - // Restore audio from base64 to blobs for export - const { restoreAudioFromStorage } = await import('@/lib/audioUtils'); - const nodes = loadedNodes.map((node) => { - if (node.data?.dialogueRows) { - const restoredRows = normalizeDialogueRows(node.data.dialogueRows).map((row) => { - if (row.audioFile?.base64) { - const restoredAudio = restoreAudioFromStorage(row.audioFile); - return { ...row, audioFile: restoredAudio }; - } - return row; - }); - return { ...node, data: { ...node.data, dialogueRows: restoredRows } }; - } - return node; - }); - // Export must operate on the restored node graph so audio blobs are available. - let persistedNodes = nodes; - let effectiveStringTableEntries = stringTableEntries; - let localizedPreviewNodes = materializeLocalizedNodes({ - nodes: persistedNodes, - dialogueId, - dialogueSlug: dialogue.localizationSlug, - locale: localizationState.defaultLocale, - defaultLocale: localizationState.defaultLocale, - stringEntries: effectiveStringTableEntries, - }); - let exportValidation = validateLocalizedEntriesForDialogue({ - nodes: localizedPreviewNodes, - entries: effectiveStringTableEntries, - defaultLocale: localizationState.defaultLocale, - }); - if (!exportValidation.valid && dialogue.projectId) { - const repaired = prepareLocalizedNodesAndEntries({ - projectId: dialogue.projectId, - dialogueId, - dialogueSlug: dialogue.localizationSlug, - nodes: localizedPreviewNodes, - locale: localizationState.defaultLocale, - existingEntries: effectiveStringTableEntries, - }); - const repairedValidation = validateLocalizedEntriesForDialogue({ - nodes: repaired.nodes, - entries: repaired.entries, - defaultLocale: localizationState.defaultLocale, - }); - if (!repairedValidation.valid) { - throw new Error( - `Dialogue export blocked by invalid localization references: ${summarizeLocalizationValidationErrors( - repairedValidation.errors - )}` - ); - } - await db.transaction('rw', [db.nodes, db.localizedStrings, db.dialogues], async () => { - await db.nodes.where('dialogueId').equals(dialogueId).delete(); - const repairedPersistedNodes = buildPersistedNodesWithoutLocalizedText( - repaired.nodes, - dialogueId - ); - if (repairedPersistedNodes.length > 0) { - await db.nodes.bulkAdd(repairedPersistedNodes); - } - await db.localizedStrings.where('dialogueId').equals(dialogueId).delete(); - await db.localizedStrings.bulkPut(repaired.entries); - await db.dialogues.update(dialogueId, { - localizationSlug: dialogue.localizationSlug, - localizationVersion: 2, - modifiedAt: new Date().toISOString(), - }); - }); - useSyncStore.getState().schedulePush(dialogue.projectId); - persistedNodes = repaired.nodes; - effectiveStringTableEntries = repaired.entries; - localizedPreviewNodes = materializeLocalizedNodes({ - nodes: persistedNodes, - dialogueId, - dialogueSlug: dialogue.localizationSlug, - locale: localizationState.defaultLocale, - defaultLocale: localizationState.defaultLocale, - stringEntries: effectiveStringTableEntries, - }); - exportValidation = repairedValidation; - } - if (!exportValidation.valid) { - throw new Error( - `Dialogue export blocked by invalid localization references: ${summarizeLocalizationValidationErrors( - exportValidation.errors - )}` - ); - } - - console.log(`[exportDialogueAsBlob] Found ${nodes.length} nodes and ${edges.length} edges`); - // Validate Start Node exists - const startNode = nodes.find((n) => n.id === '00000000-0000-0000-0000-000000000001'); - if (!startNode) { - throw new Error('Export failed: Start Node (00000000-0000-0000-0000-000000000001) is missing from the dialogue'); - } - - // Collect unique participants and used decorator/condition references from nodes/edges. - const participantSet = new Set(); - const usedDecoratorIds = new Set(); - const usedDecoratorNames = new Set(); - const usedConditionIds = new Set(); - const usedConditionNames = new Set(); - const allDialogueRows = []; - - localizedPreviewNodes.forEach((node) => { - // Extract participant - if (node.data?.participant) { - participantSet.add(node.data.participant); - } - - // Extract dialogue rows - if (node.data?.dialogueRows) { - normalizeDialogueRows(node.data.dialogueRows).forEach((row) => { - allDialogueRows.push({ - id: row.id, - textKey: row.textKey || '', - audioPath: row.audioFile?.blob ? `audio/${row.id}/` : null, - nodeId: node.id, - duration: row.duration || 0, - }); - }); - } - - // Track used decorators from node instances. - if (Array.isArray(node.data?.decorators)) { - node.data.decorators.forEach((decorator) => { - const decoratorId = String(decorator?.id || '').trim(); - const decoratorName = String(decorator?.name || '').trim(); - if (decoratorId) usedDecoratorIds.add(decoratorId); - if (decoratorName) usedDecoratorNames.add(decoratorName); - }); - } - }); - - // Track used conditions from edge instances. - (edges || []).forEach((edge) => { - const rules = Array.isArray(edge?.data?.conditions?.rules) ? edge.data.conditions.rules : []; - rules.forEach((rule) => { - const conditionId = String(rule?.id || '').trim(); - const conditionName = String(rule?.name || '').trim(); - if (conditionId) usedConditionIds.add(conditionId); - if (conditionName) usedConditionNames.add(conditionName); - }); - }); - - // Load participants and categories from database - const categories = await db.categories - .where('projectId') - .equals(dialogue.projectId) - .toArray(); - - const participants = await db.participants - .where('projectId') - .equals(dialogue.projectId) - .toArray(); - const decorators = await db.decorators.where('projectId').equals(dialogue.projectId).toArray(); - const conditions = await db.conditions.where('projectId').equals(dialogue.projectId).toArray(); - - // Filter to only used participants - const usedParticipants = participants.filter((p) => - participantSet.has(p.name) - ); - - // Get categories for used participants - const usedCategoryNames = new Set(usedParticipants.map((p) => p.category)); - const usedCategories = categories.filter((c) => - usedCategoryNames.has(c.name) - ); - - // Build category path map - const { useCategoryStore } = await import('./categoryStore'); - const categoryPathMap = new Map(); - categories.forEach((cat) => { - const fullPath = useCategoryStore.getState().buildCategoryPath(cat.id, categories); - categoryPathMap.set(cat.name, fullPath); - }); - - // Prepare export data - const dialogueData = { - dialogueGuid: dialogue.id, - id: dialogue.id, - dialogueName: dialogue.name, - localizationSlug: dialogue.localizationSlug, - localizationVersion: dialogue.localizationVersion || 2, - modifiedOnDate: dialogue.modifiedAt || new Date().toISOString(), - }; - - const categoriesExport = usedCategories.map((cat) => ({ - name: cat.name, - fullPath: categoryPathMap.get(cat.name) || cat.name, - })); - - const participantsExport = usedParticipants.map((p) => ({ - name: p.name, - fullPath: categoryPathMap.get(p.category) || p.category, - participantImage: p.thumbnail - ? buildParticipantImageId({ - participantName: p.name, - categoryPath: categoryPathMap.get(p.category) || p.category, - }) - : null, - })); - - const exportDefinition = (entry) => { - const exported = { ...(entry || {}) }; - delete exported.projectId; - delete exported.createdAt; - delete exported.modifiedAt; - return exported; - }; - const decoratorsExport = decorators - .filter((definition) => { - const definitionId = String(definition?.id || '').trim(); - const definitionName = String(definition?.name || '').trim(); - return usedDecoratorIds.has(definitionId) || usedDecoratorNames.has(definitionName); - }) - .map(exportDefinition); - const conditionsExport = conditions - .filter((definition) => { - const definitionId = String(definition?.id || '').trim(); - const definitionName = String(definition?.name || '').trim(); - return usedConditionIds.has(definitionId) || usedConditionNames.has(definitionName); - }) - .map(exportDefinition); - - // Create ZIP file - const zip = new JSZip(); - - // Strip audio data from nodes for JSON serialization - // (actual audio files go in the audio/ folder) - const nodesForExport = persistedNodes.map((node) => { - if (!node.data?.dialogueRows) { - return node; - } - const rowsWithoutAudio = node.data.dialogueRows.map((row) => { - const rowWithoutAudio = { ...(row || {}) }; - delete rowWithoutAudio.audioFile; - delete rowWithoutAudio.text; - return rowWithoutAudio; - }); - return { ...node, data: { ...node.data, dialogueRows: rowsWithoutAudio } }; - }); - - // Add JSON files - zip.file('dialogueData.json', JSON.stringify(dialogueData, null, 2)); - zip.file('categories.json', JSON.stringify(categoriesExport, null, 2)); - zip.file('participants.json', JSON.stringify(participantsExport, null, 2)); - zip.file('nodes.json', JSON.stringify(nodesForExport, null, 2)); - zip.file('edges.json', JSON.stringify(edges, null, 2)); - zip.file('dialogueRows.json', JSON.stringify(allDialogueRows, null, 2)); - zip.file('decorators.json', JSON.stringify(decoratorsExport, null, 2)); - zip.file('conditions.json', JSON.stringify(conditionsExport, null, 2)); - zip.file( - 'stringTable.json', - JSON.stringify( - buildStringTableV2Payload({ - dialogueId, - defaultLocale: localizationState.defaultLocale, - locales: localizationState.supportedLocales, - entries: effectiveStringTableEntries, - }), - null, - 2 - ) - ); - - // Add audio files - const audioFolder = zip.folder('audio'); - for (const row of allDialogueRows) { - if (row.audioPath) { - // Find the actual audio file from nodes - const node = localizedPreviewNodes.find((n) => - n.data?.dialogueRows?.some((r) => r.id === row.id) - ); - if (node) { - const dialogueRow = node.data.dialogueRows.find((r) => r.id === row.id); - if (dialogueRow?.audioFile?.blob) { - const rowFolder = audioFolder.folder(row.id); - const safeAudioName = sanitizeAudioFileName(dialogueRow.audioFile.name, 'Audio'); - rowFolder.file(safeAudioName, dialogueRow.audioFile.blob); - } - } - } - } - - // Add participant thumbnail files unless explicitly disabled (used by project export). - const includeThumbnails = options?.includeThumbnails !== false; - if (includeThumbnails) { - const thumbnailsFolder = zip.folder('Thumbnails'); - participantsExport.forEach((entry, index) => { - const participantImageId = String(entry?.participantImage || '').trim(); - if (!participantImageId) return; - const participant = usedParticipants[index]; - try { - const thumbnailBlob = storedParticipantThumbnailToBlob(participant?.thumbnail); - if (!thumbnailBlob) return; - thumbnailsFolder.file(`${participantImageId}.png`, thumbnailBlob); - } catch (error) { - console.warn( - `Skipping invalid participant thumbnail for ${participant?.name || participantImageId}` - ); - } - }); - } - - // Generate and return blob - const blob = await zip.generateAsync({ type: 'blob' }); - return blob; - } catch (error) { - console.error('Error exporting dialogue as blob:', error); - throw error; - } + exportDialogueAsBlob: async (dialogueId, options = {}) => { + const context = options.context || await getRepositoryContext(); + context.assertCurrent(); + const dialogue = await context.db.dialogues.get(dialogueId); + context.assertCurrent(); + if (!dialogue) throw new Error('Dialogue not found'); + const snapshot = await buildProjectSnapshot(dialogue.projectId, { context, requireReady: true }); + // Include child graphs transitively so every target is resolvable on import. + const selected = new Set([dialogueId]); + let changed = true; + while (changed) { changed = false; for (const node of snapshot.nodes) if (selected.has(node.dialogueId) && node.data?.targetDialogue && !selected.has(node.data.targetDialogue)) { selected.add(node.data.targetDialogue); changed = true; } } + for (const table of ['dialogues', 'nodes', 'edges', 'localizedStrings']) snapshot[table] = snapshot[table].filter((row) => selected.has(table === 'dialogues' ? row.id : row.dialogueId)); + return exportProjectArchive(snapshot, { kind: 'dialogue', dialogueId }); }, - - // Import dialogue from .mnteadlg file - importDialogue: async (projectId, file) => { + importDialogue: async (projectId, file, options = {}) => { + const context = await getRepositoryContext(); try { - // Load JSZip dynamically - const JSZip = (await import('jszip')).default; - - // Read the ZIP file - const zip = await JSZip.loadAsync(file); - - // Extract all JSON files - const dialogueDataStr = await zip.file('dialogueData.json')?.async('text'); - const categoriesStr = await zip.file('categories.json')?.async('text'); - const participantsStr = await zip.file('participants.json')?.async('text'); - const decoratorsStr = await zip.file('decorators.json')?.async('text'); - const nodesStr = await zip.file('nodes.json')?.async('text'); - const edgesStr = await zip.file('edges.json')?.async('text'); - const dialogueRowsStr = await zip.file('dialogueRows.json')?.async('text'); - const stringTableStr = await zip.file('stringTable.json')?.async('text'); - - // Validate all required files exist - if (!dialogueDataStr || !nodesStr || !edgesStr) { - throw new Error('Invalid dialogue file: missing required files'); - } - - // Parse JSON - const dialogueData = JSON.parse(dialogueDataStr); - const categories = categoriesStr ? JSON.parse(categoriesStr) : []; - const participants = participantsStr ? JSON.parse(participantsStr) : []; - const decorators = decoratorsStr ? JSON.parse(decoratorsStr) : []; - const nodes = JSON.parse(nodesStr); - const edges = JSON.parse(edgesStr); - const dialogueRows = dialogueRowsStr ? JSON.parse(dialogueRowsStr) : []; - const stringTableData = stringTableStr ? JSON.parse(stringTableStr) : null; - const importedStringEntries = parseImportedStringTableData(stringTableData); - // Validate Start Node exists - const startNode = nodes.find((n) => n.id === '00000000-0000-0000-0000-000000000001'); - if (!startNode) { - throw new Error('Import failed: Start Node (00000000-0000-0000-0000-000000000001) is missing from the dialogue file'); - } - - // Step 1: Import categories with deduplication - const { useCategoryStore } = await import('./categoryStore'); - const categoryStore = useCategoryStore.getState(); - const existingCategories = await db.categories - .where('projectId') - .equals(projectId) - .toArray(); - - // Build map of existing category paths - const existingCategoryPaths = new Map(); - existingCategories.forEach((cat) => { - const fullPath = categoryStore.buildCategoryPath(cat.id, existingCategories); - existingCategoryPaths.set(fullPath, cat); - }); - - // Import categories that don't exist - directly into DB to avoid conflicts - const categoryMapping = new Map(); // fullPath -> categoryName - const now = new Date().toISOString(); - for (const catData of categories) { - const fullPath = catData.fullPath; - if (!existingCategoryPaths.has(fullPath)) { - // Create the category hierarchy - const pathParts = fullPath.split('.'); - let parentId = null; - let currentPath = ''; - - for (const part of pathParts) { - currentPath = currentPath ? `${currentPath}.${part}` : part; - - // Check if this level already exists - if (!existingCategoryPaths.has(currentPath)) { - const newCat = { - id: uuidv4(), - name: part, - parentCategoryId: parentId, - projectId, - createdAt: now, - modifiedAt: now, - }; - await db.categories.add(newCat); - existingCategoryPaths.set(currentPath, newCat); - parentId = newCat.id; - } else { - parentId = existingCategoryPaths.get(currentPath).id; - } - } - } - categoryMapping.set(fullPath, catData.name); - } - - // Step 2: Import participants with deduplication - const participantThumbnailById = new Map(); - for (const partData of participants) { - const participantImageId = String(partData?.participantImage || '').trim(); - if (!participantImageId) continue; - const thumbnailEntry = - zip.file(`Thumbnails/${participantImageId}.png`) || - zip.file(`thumbnails/${participantImageId}.png`); - if (!thumbnailEntry) continue; - try { - const thumbnailBlob = await thumbnailEntry.async('blob'); - const thumbnail = await blobToStoredParticipantThumbnail(thumbnailBlob); - participantThumbnailById.set(participantImageId, thumbnail); - } catch (error) { - console.warn( - `Skipping invalid participant thumbnail in dialogue import (${participantImageId})` - ); - } - } - - const existingParticipants = await db.participants - .where('projectId') - .equals(projectId) - .toArray(); - - const existingParticipantsByName = new Map( - existingParticipants.map((participant) => [participant.name, participant]) - ); - const existingParticipantNames = new Set(existingParticipants.map((p) => p.name)); - const participantMapping = new Map(); // old name -> new name - - for (const partData of participants) { - const participantImageId = String(partData?.participantImage || '').trim(); - const importedThumbnail = participantImageId - ? participantThumbnailById.get(participantImageId) || null - : null; - - if (!existingParticipantNames.has(partData.name)) { - // Find the category by fullPath - const categoryName = categoryMapping.get(partData.fullPath) || - existingCategoryPaths.get(partData.fullPath)?.name; - - if (!categoryName) { - throw new Error(`Category not found for participant ${partData.name}: ${partData.fullPath}`); - } - - // Directly insert to avoid conflicts - const newParticipant = { - id: uuidv4(), - name: partData.name, - category: categoryName, - thumbnail: importedThumbnail, - projectId, - createdAt: now, - modifiedAt: now, - }; - await db.participants.add(newParticipant); - existingParticipantNames.add(partData.name); - existingParticipantsByName.set(partData.name, newParticipant); - } else if (importedThumbnail) { - const existingParticipant = existingParticipantsByName.get(partData.name); - if (existingParticipant && !existingParticipant.thumbnail) { - await db.participants.update(existingParticipant.id, { - thumbnail: importedThumbnail, - modifiedAt: now, - }); - existingParticipantsByName.set(partData.name, { - ...existingParticipant, - thumbnail: importedThumbnail, - modifiedAt: now, - }); - } - } - participantMapping.set(partData.name, partData.name); - } - - // Step 3: Import decorators with deduplication - const existingDecorators = await db.decorators - .where('projectId') - .equals(projectId) - .toArray(); - - // Build map of existing decorators by name - const existingDecoratorMap = new Map(); - existingDecorators.forEach((dec) => { - existingDecoratorMap.set(dec.name, dec); - }); - - // Track unique decorator names from instances - const decoratorNames = new Set(); - decorators.forEach((dec) => { - decoratorNames.add(dec.name); - }); - - // Import decorator definitions that don't exist - for (const name of decoratorNames) { - if (!existingDecoratorMap.has(name)) { - // Create a basic decorator definition (properties will be empty since we only have instances) - const newDec = { - id: uuidv4(), - name: name, - type: '', - properties: [], - projectId, - createdAt: now, - modifiedAt: now, - }; - await db.decorators.add(newDec); - existingDecoratorMap.set(name, newDec); - } - } - - // Step 4: Validate dialogue name - if (!dialogueData.dialogueName || !dialogueData.dialogueName.trim()) { - throw new Error('Invalid dialogue name'); - } - - // Check for name conflicts - const existingDialogues = await db.dialogues - .where('projectId') - .equals(projectId) - .toArray(); - - let finalName = dialogueData.dialogueName; - let counter = 1; - const dialogueGuid = String(dialogueData?.dialogueGuid || '').trim(); - const fallbackDialogueId = String(dialogueData?.id || '').trim(); - if (dialogueGuid && fallbackDialogueId && dialogueGuid !== fallbackDialogueId) { - console.warn('[importDialogue] dialogueGuid/id mismatch; using dialogueGuid', { - dialogueGuid, - id: fallbackDialogueId, - }); - } - const dialogueId = dialogueGuid || fallbackDialogueId; - if (!dialogueId) { - throw new Error('Invalid dialogue file: missing dialogue GUID'); - } - - const otherDialogues = existingDialogues.filter((d) => d.id !== dialogueId); - while (otherDialogues.some((d) => d.name === finalName)) { - finalName = `${dialogueData.dialogueName} (${counter})`; - counter++; - } - - // Step 5: Create the dialogue — preserve the original ID so cross-dialogue - // references (e.g. openChildGraphNode.targetDialogue) remain valid after import. - const newDialogue = { - id: dialogueId, - projectId, - name: finalName, - description: '', - nodeCount: nodes.length, - localizationSlug: ensureDialogueLocalizationSlug({ - localizationSlug: dialogueData?.localizationSlug, - name: finalName, - id: dialogueId, - }), - localizationVersion: 2, - createdAt: now, - modifiedAt: now, - }; - - await db.dialogues.put(newDialogue); - - // Step 6: Import nodes - // Build a map of old node IDs to new node IDs - const nodeIdMapping = new Map(); - const rowIdMapping = new Map(); - const mapNodeReference = (rawNodeId) => { - const nodeId = String(rawNodeId || '').trim(); - if (!nodeId) return ''; - return ( - nodeIdMapping.get(nodeId) || - nodeIdMapping.get(nodeId.toLowerCase()) || - nodeIdMapping.get(nodeId.toUpperCase()) || - '' - ); - }; - const mapRowReference = (rawNodeId, rawRowId) => { - const nodeId = String(rawNodeId || '').trim(); - const rowId = String(rawRowId || '').trim(); - if (!nodeId || !rowId) return ''; - const composite = `${nodeId}:${rowId}`; - const lowerComposite = `${nodeId.toLowerCase()}:${rowId.toLowerCase()}`; - const upperComposite = `${nodeId.toUpperCase()}:${rowId.toUpperCase()}`; - return ( - rowIdMapping.get(composite) || - rowIdMapping.get(lowerComposite) || - rowIdMapping.get(upperComposite) || - '' - ); - }; - const importedNodesForLocalization = []; - - for (const node of nodes) { - const oldNodeId = String(node.id || '').trim(); - const newNodeId = node.type === 'startNode' - ? '00000000-0000-0000-0000-000000000001' - : oldNodeId; - - nodeIdMapping.set(oldNodeId, newNodeId); - nodeIdMapping.set(oldNodeId.toLowerCase(), newNodeId); - nodeIdMapping.set(oldNodeId.toUpperCase(), newNodeId); - - // Process node data - const nodeData = { ...node.data }; - - // Update participant reference - if (nodeData.participant) { - // participant might be a string or an object - if (typeof nodeData.participant === 'object' && nodeData.participant.name) { - nodeData.participant = nodeData.participant.name; - } - } - - // Update decorators - remove nodeId as it's stored in node data - if (nodeData.decorators) { - nodeData.decorators = nodeData.decorators.map((dec) => { - // Find matching decorator definition - const existingDec = existingDecoratorMap.get(dec.name); - return { - id: existingDec?.id || dec.id, - name: dec.name, - values: dec.values || {}, - }; - }); - } - // Normalize node references (legacy exports may differ only by UUID casing). - if (typeof nodeData.targetNode === 'string' && nodeData.targetNode.trim()) { - const remappedTargetNode = mapNodeReference(nodeData.targetNode); - if (remappedTargetNode) { - nodeData.targetNode = remappedTargetNode; - } - } - - // Normalize rows so each row has a stable ID for audio rebinding. - if (nodeData.dialogueRows) { - const rawRows = nodeData.dialogueRows; - nodeData.dialogueRows = normalizeDialogueRows(rawRows).map((row, idx) => { - const rawRow = rawRows[idx] || {}; - const oldRowId = String(rawRow.id || row.id || '').trim(); - const newRowId = uuidv4(); - rowIdMapping.set(`${oldNodeId}:${oldRowId}`, newRowId); - rowIdMapping.set(`${oldNodeId.toLowerCase()}:${oldRowId.toLowerCase()}`, newRowId); - rowIdMapping.set(`${oldNodeId.toUpperCase()}:${oldRowId.toUpperCase()}`, newRowId); - const nextRow = { ...row, id: newRowId }; - // Remove synthetic empty text added by normalizeDialogueRow when the original - // row uses textKey (text lives in the string table, not inline). Without this, - // prepareLocalizedNodesAndEntries would overwrite the default-locale string - // table value with an empty string, causing EN rows to appear blank after import. - if (nextRow.textKey && !Object.prototype.hasOwnProperty.call(rawRow, 'text')) { - delete nextRow.text; - } - return nextRow; - }); - } - - const newNode = { - id: newNodeId, - dialogueId, - type: node.type, - position: node.position, - data: nodeData, - }; - - importedNodesForLocalization.push(newNode); - } - - // Step 7: Import edges with updated node IDs - const remappedEdges = []; - for (const edge of edges) { - const newEdge = { - id: uuidv4(), - dialogueId, - source: mapNodeReference(edge.source), - target: mapNodeReference(edge.target), - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - markerEnd: edge.markerEnd, - }; - - remappedEdges.push(newEdge); - } - - // Step 8: Import optional StringTable data and remap to current dialogue metadata - let localizedEntriesToUpsert = []; - if (importedStringEntries.length > 0) { - const remappedEntries = remapLocalizedEntriesForImportedDialogue({ - entries: importedStringEntries, - newProjectId: projectId, - newDialogueId: dialogueId, - newDialogueSlug: newDialogue.localizationSlug, - nodeIdMap: nodeIdMapping, - rowIdMap: rowIdMapping, - }); - if (remappedEntries.length > 0) { - localizedEntriesToUpsert = remappedEntries; - } - } - - const project = await db.projects.get(projectId); - const localizationState = getProjectLocalizationState(project); - const preparedImport = prepareLocalizedNodesAndEntries({ - projectId, - dialogueId, - dialogueSlug: newDialogue.localizationSlug, - nodes: importedNodesForLocalization, - locale: localizationState.defaultLocale, - existingEntries: localizedEntriesToUpsert, - }); - const validation = validateLocalizedEntriesForDialogue({ - nodes: preparedImport.nodes, - entries: preparedImport.entries, - defaultLocale: localizationState.defaultLocale, - }); - if (!validation.valid) { - throw new Error('Imported dialogue contains invalid localization references.'); - } - await db.transaction('rw', [db.nodes, db.edges, db.localizedStrings], async () => { - const importPersistedNodes = buildPersistedNodesWithoutLocalizedText( - preparedImport.nodes, - dialogueId - ); - if (importPersistedNodes.length > 0) { - await db.nodes.bulkPut(importPersistedNodes); - } - await db.edges.where('dialogueId').equals(dialogueId).delete(); - if (remappedEdges.length > 0) { - await db.edges.bulkAdd(remappedEdges); - } - await db.localizedStrings.where('dialogueId').equals(dialogueId).delete(); - if (preparedImport.entries.length > 0) { - await db.localizedStrings.bulkPut(preparedImport.entries); - } - }); - // Step 9: Process audio files from dialogueRows - // Note: Audio files in the export are stored as blobs in the ZIP - // We'll need to extract them if they exist - const audioFolder = zip.folder('audio'); - if (audioFolder) { - // Process each dialogue row's audio - for (const row of dialogueRows) { - if (row.audioPath) { - // The audioPath is like "audio/rowId/" - // Look for audio files in that subfolder - const rowFolder = audioFolder.folder(row.id); - if (rowFolder) { - const { selectedPath: selectedAudioPath, warnings } = - resolveRowAudioImportSelection(rowFolder, row.id); - warnings.forEach((warning) => { - console.warn('[importDialogue:audio]', warning); - }); - - if (selectedAudioPath) { - const audioFile = rowFolder.files[selectedAudioPath]; - if (!audioFile) { - console.warn('[importDialogue:audio]', { - code: 'selected_audio_not_found', - rowId: row.id, - selectedAudioPath, - }); - continue; - } - const audioBlob = await audioFile.async('blob'); - const importedFileName = selectedAudioPath.split('/').pop(); - const safeImportedFileName = sanitizeAudioFileName(importedFileName, 'Audio'); - - // Store audio in IndexedDB - // We'll need to update the node's dialogue row with audio data - const nodeId = mapNodeReference(row.nodeId); - const remappedRowId = - mapRowReference(row.nodeId, row.id) || row.id; - if (nodeId) { - const nodePrimaryKey = [dialogueId, nodeId]; - const node = await db.nodes.get(nodePrimaryKey); - if (node && node.data.dialogueRows) { - const normalizedRows = node.data.dialogueRows.map((item) => ({ - ...(item || {}), - })); - const rowIndex = normalizedRows.findIndex((r) => r.id === remappedRowId); - if (rowIndex !== -1) { - // Create audio file data - const audioFileData = { - id: uuidv4(), - name: safeImportedFileName, - type: audioBlob.type, - size: audioBlob.size, - blob: audioBlob, - path: `audio/${remappedRowId}/${safeImportedFileName}`, - }; - - normalizedRows[rowIndex].audioFile = audioFileData; - normalizedRows[rowIndex].duration = row.duration; - delete normalizedRows[rowIndex].text; - node.data.dialogueRows = normalizedRows; - - await db.nodes.update(nodePrimaryKey, { data: node.data }); - } - } - } - } - } - } - } - } - - // Reload dialogues - await get().loadDialogues(projectId); + const imported = await prepareArchiveImport(file, { ...options, projectId, context }); + if (imported.projectId !== projectId) throw new Error('Choose a dialogue archive for this destination.'); + await commitPreparedProject(imported.prepared); context.assertCurrent(); + await get().loadDialogues(projectId); context.assertCurrent(); useSyncStore.getState().schedulePush(projectId); - - toast({ - variant: 'success', - title: 'Dialogue Imported', - description: `${finalName} has been imported successfully`, - }); - - return newDialogue; - } catch (error) { - console.error('Error importing dialogue:', error); - toast({ - variant: 'error', - title: 'Failed to Import Dialogue', - description: error.message || 'An unexpected error occurred', - }); - // Don't re-throw - error has been handled with toast - } + toast({ variant: 'success', title: 'Dialogue Imported', description: 'Dialogue imported successfully' }); + return imported.prepared.revision.snapshot.dialogues.find((row) => row.id === imported.firstDialogueId); + } catch (error) { if (error.code !== 'STALE_PROFILE') toast(errorToast(error, 'import')); throw error; } }, })); diff --git a/src/stores/domainStoreSupport.js b/src/stores/domainStoreSupport.js new file mode 100644 index 0000000..041930f --- /dev/null +++ b/src/stores/domainStoreSupport.js @@ -0,0 +1,44 @@ +import { errorToast } from '@/lib/errorPresentation'; +import { getRepositoryContext } from '@/lib/db'; +import { mutateProject } from '@/lib/persistence/projectRepository'; +import { toast } from '@/components/ui/toaster'; +import { useSyncStore } from '@/stores/syncStore'; +import { projectIdentityLabels } from '@/lib/domainIntegrity'; + +export async function loadDomainRecords(set, table, projectId, suppliedContext) { + const context = suppliedContext || await getRepositoryContext(); + context.assertCurrent(); set({ isLoading: true }); + try { + const records = await context.db[table].where('projectId').equals(projectId).toArray(); + context.assertCurrent(); set({ [table]: records, isLoading: false }); + return records; + } catch (error) { + if (!context.signal.aborted) set({ isLoading: false }); + throw error; + } +} +export async function changeDomainRecords({ context, projectId, transform, set, table, message, achievement }) { + try { + const result = await mutateProject(projectId, { context, transform: (snapshot) => { + projectIdentityLabels(snapshot, { strict: false }); + transform(snapshot); + projectIdentityLabels(snapshot, { strict: false }); + return snapshot; + } }); + context.assertCurrent(); set({ [table]: result.snapshot[table], isLoading: false }); + useSyncStore.getState().schedulePush(projectId); + if (message) toast({ variant: 'success', title: message }); + if (achievement) await achievement(projectId).catch((error) => console.warn('[achievements]', error)); + context.assertCurrent(); return result.snapshot; + } catch (error) { + if (!context.signal.aborted) toast(errorToast(error, 'update')); + throw error; + } +} +export async function findDomainRecord(table, id) { + const context = await getRepositoryContext(); + const record = await context.db[table].get(id); + context.assertCurrent(); + if (!record) throw new Error(`${table} record not found: ${id}`); + return { context, record }; +} diff --git a/src/stores/participantStore.js b/src/stores/participantStore.js index 79c2216..6156c8d 100644 --- a/src/stores/participantStore.js +++ b/src/stores/participantStore.js @@ -1,527 +1,119 @@ import { create } from 'zustand'; -import { db } from '@/lib/db'; -import { v4 as uuidv4 } from 'uuid'; -import { toast } from '@/components/ui/toaster'; -import { useCategoryStore } from './categoryStore'; -import { useSyncStore } from '@/stores/syncStore'; +import { getRepositoryContext, readProjectRecords, assertProjectReady } from '@/lib/db'; import { trackFirstParticipantCreated } from '@/lib/achievements/achievementTracker'; -import { - blobToStoredParticipantThumbnail, - buildParticipantImageId, - resolveParticipantThumbnailDataUrl, - storedParticipantThumbnailToBlob, -} from '@/lib/participantThumbnails'; -import { PARTICIPANT_THUMBNAIL_MAX_BYTES } from '@/lib/sync/core/constants'; -import { buildProjectSnapshot } from '@/lib/sync/snapshot'; -import { assertEstimatedSyncPayloadWithinBudget } from '@/lib/sync/payloadBudget'; - -function getRootIdByCategoryName(categoryName, categories = []) { - const match = categories.find((category) => category.name === categoryName); - if (!match) return null; - return useCategoryStore.getState().getRootCategoryId(match.id, categories); +import { blobToStoredParticipantThumbnail, buildParticipantImageId, resolveParticipantThumbnailDataUrl, storedParticipantThumbnailToBlob } from '@/lib/participantThumbnails'; +import { assertUnreferenced, buildCategoryPath, normalizeParticipant, prepareCategoryImport, resolveReference } from '@/lib/domainIntegrity'; +import { changeDomainRecords, findDomainRecord, loadDomainRecords } from './domainStoreSupport'; + +function normalizeThumbnail(thumbnail) { + if (!thumbnail) return null; + const base64 = resolveParticipantThumbnailDataUrl(thumbnail); + if (!base64 || !base64.startsWith('data:image/png;base64,')) throw new Error('Invalid PNG participant thumbnail payload'); + const sizeBytes = atob(base64.split(',')[1]).length; + if (sizeBytes > 1024 * 1024) throw new Error('Participant thumbnail must be 1 MB or smaller'); + return { ...thumbnail, base64, mimeType: 'image/png', sizeBytes, updatedAt: thumbnail.updatedAt || new Date().toISOString() }; } - -function getImportedThumbnailById(thumbnailById, participantImageId) { - if (!participantImageId || !thumbnailById) return null; - if (thumbnailById instanceof Map) { - return thumbnailById.get(participantImageId) || null; - } - if (typeof thumbnailById === 'object') { - return thumbnailById[participantImageId] || null; - } - return null; +function thumbnailFor(map, id) { return map instanceof Map ? map.get(id) : map?.[id]; } +function participantManifest(participants, categories) { + return participants.map((participant) => { + const category = resolveReference(categories, participant.categoryId, participant.category, 'Category'); + const fullPath = buildCategoryPath(category.id, categories); + return { ...participant, categoryId: category.id, fullPath, participantImage: participant.thumbnail ? `${buildParticipantImageId({ participantName: participant.name, categoryPath: fullPath })}_${participant.id}` : null }; + }); } - -function normalizeStoredThumbnail(thumbnail) { - if (!thumbnail || typeof thumbnail !== 'object') return null; - const base64 = resolveParticipantThumbnailDataUrl(thumbnail); - if (!base64) { - throw new Error('Invalid participant thumbnail payload'); - } - - const reportedSize = Number(thumbnail.sizeBytes || 0); - const estimatedSize = Math.max(0, Math.floor((base64.length * 3) / 4)); - const sizeBytes = reportedSize > 0 ? reportedSize : estimatedSize; - if (sizeBytes > PARTICIPANT_THUMBNAIL_MAX_BYTES) { - throw new Error('Participant thumbnail must be 1 MB or smaller'); - } - - return { - base64, - mimeType: 'image/png', - sizeBytes, - width: Number(thumbnail.width || 0), - height: Number(thumbnail.height || 0), - updatedAt: String(thumbnail.updatedAt || new Date().toISOString()), - }; -} - -async function assertParticipantMutationSyncBudget(projectId, nextParticipants) { - const snapshot = await buildProjectSnapshot(projectId); - snapshot.participants = nextParticipants; - assertEstimatedSyncPayloadWithinBudget(snapshot, 'Project sync payload'); -} - export const useParticipantStore = create((set, get) => ({ - participants: [], - isLoading: false, - maxParticipantNameLength: 16, - - // Load participants for a specific project - loadParticipants: async (projectId) => { - set({ isLoading: true }); - try { - const participants = await db.participants.where('projectId').equals(projectId).toArray(); - set({ participants, isLoading: false }); - } catch (error) { - console.error('Failed to load participants:', error); - toast({ - variant: 'error', - title: 'Failed to Load Participants', - description: error.message || 'An unexpected error occurred', - }); - set({ isLoading: false }); - } - }, - - // Create a new participant - createParticipant: async (participantData) => { - try { - const name = participantData.name?.trim() || ''; - if (!name) { - throw new Error('Participant name is required'); - } - if (!/^[A-Za-z0-9]+$/.test(name)) { - throw new Error('Participant name must contain only letters and numbers'); - } - if (name.length > get().maxParticipantNameLength) { - throw new Error( - `Participant name must be ${get().maxParticipantNameLength} characters or fewer` - ); - } - if (!participantData.category) { - throw new Error('Participant category is required'); - } - - const categories = await db.categories - .where('projectId') - .equals(participantData.projectId) - .toArray(); - const participants = await db.participants - .where('projectId') - .equals(participantData.projectId) - .toArray(); - - const rootId = getRootIdByCategoryName(participantData.category, categories); - if (rootId) { - const isDuplicate = participants.some((participant) => { - if (participant.name !== name) return false; - const participantRootId = getRootIdByCategoryName(participant.category, categories); - return participantRootId === rootId; - }); - if (isDuplicate) { - throw new Error('Participant name must be unique within its category tree'); - } - } - - const now = new Date().toISOString(); - const id = uuidv4(); - const thumbnail = normalizeStoredThumbnail(participantData.thumbnail || null); - const newParticipant = { - id, - ...participantData, - name, - thumbnail, - createdAt: now, - modifiedAt: now, - }; - - await assertParticipantMutationSyncBudget(participantData.projectId, [ - ...participants, - newParticipant, - ]); - - await db.participants.add(newParticipant); - try { - await trackFirstParticipantCreated(participantData.projectId); - } catch (error) { - console.warn('[achievements] Failed to track first participant:', error); - } - await get().loadParticipants(participantData.projectId); - useSyncStore.getState().schedulePush(participantData.projectId); - toast({ - variant: 'success', - title: 'Participant Created', - description: `${participantData.name} has been created successfully`, - }); - return newParticipant; - } catch (error) { - console.error('Error creating participant:', error); - toast({ - variant: 'error', - title: 'Failed to Create Participant', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Update a participant - updateParticipant: async (id, updates) => { - try { - const participant = await db.participants.get(id); - if (!participant) { - throw new Error('Participant not found'); - } - - const name = (updates.name ?? participant.name)?.trim() || ''; - if (!name) { - throw new Error('Participant name is required'); - } - if (!/^[A-Za-z0-9]+$/.test(name)) { - throw new Error('Participant name must contain only letters and numbers'); - } - if (name.length > get().maxParticipantNameLength) { - throw new Error( - `Participant name must be ${get().maxParticipantNameLength} characters or fewer` - ); - } - const categoryName = updates.category ?? participant.category; - if (!categoryName) { - throw new Error('Participant category is required'); - } - - const categories = await db.categories - .where('projectId') - .equals(participant.projectId) - .toArray(); - const participants = await db.participants - .where('projectId') - .equals(participant.projectId) - .toArray(); - - const rootId = getRootIdByCategoryName(categoryName, categories); - if (rootId) { - const isDuplicate = participants.some((entry) => { - if (entry.id === participant.id) return false; - if (entry.name !== name) return false; - const participantRootId = getRootIdByCategoryName(entry.category, categories); - return participantRootId === rootId; - }); - if (isDuplicate) { - throw new Error('Participant name must be unique within its category tree'); - } - } - - const updatedParticipant = { - ...participant, - ...updates, - name, - category: categoryName, - thumbnail: normalizeStoredThumbnail( - Object.prototype.hasOwnProperty.call(updates, 'thumbnail') - ? updates.thumbnail - : participant.thumbnail - ), - modifiedAt: new Date().toISOString(), - }; - - const projectedParticipants = participants.map((entry) => - entry.id === participant.id ? updatedParticipant : entry - ); - await assertParticipantMutationSyncBudget(participant.projectId, projectedParticipants); - - await db.participants.update(id, updatedParticipant); - await get().loadParticipants(participant.projectId); - useSyncStore.getState().schedulePush(participant.projectId); - toast({ - variant: 'success', - title: 'Participant Updated', - description: 'Participant has been updated successfully', - }); - return updatedParticipant; - } catch (error) { - console.error('Error updating participant:', error); - toast({ - variant: 'error', - title: 'Failed to Update Participant', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Delete a participant - deleteParticipant: async (id) => { - try { - const participant = await db.participants.get(id); - if (!participant) { - throw new Error('Participant not found'); - } - - await db.participants.delete(id); - await get().loadParticipants(participant.projectId); - useSyncStore.getState().schedulePush(participant.projectId); - toast({ - variant: 'success', - title: 'Participant Deleted', - description: 'Participant has been deleted', - }); - } catch (error) { - console.error('Error deleting participant:', error); - toast({ - variant: 'error', - title: 'Failed to Delete Participant', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - importParticipantsFromFile: async (projectId, file) => { - if (!file) { - throw new Error('Missing import file'); - } - - const lowerName = String(file.name || '').toLowerCase(); - const isZip = lowerName.endsWith('.zip') || lowerName.endsWith('.mnteapart'); - if (!isZip) { - const text = await file.text(); - const parsed = JSON.parse(text); - const participantsData = Array.isArray(parsed) ? parsed : [parsed]; - return await get().importParticipants(projectId, participantsData); - } - - const JSZip = (await import('jszip')).default; - const zip = await JSZip.loadAsync(file); - const participantsJson = await zip.file('participants.json')?.async('text'); - if (!participantsJson) { - throw new Error('Invalid participants archive: missing participants.json'); - } - const parsedParticipants = JSON.parse(participantsJson); - const participantsData = Array.isArray(parsedParticipants) - ? parsedParticipants - : [parsedParticipants]; - - const thumbnailById = new Map(); - for (const participant of participantsData) { - const participantImageId = String(participant?.participantImage || '').trim(); - if (!participantImageId) continue; - const entry = - zip.file(`Thumbnails/${participantImageId}.png`) || - zip.file(`thumbnails/${participantImageId}.png`); - if (!entry) continue; - const blob = await entry.async('blob'); - const thumbnail = await blobToStoredParticipantThumbnail(blob); - thumbnailById.set(participantImageId, thumbnail); - } - - return await get().importParticipants(projectId, participantsData, { - thumbnailById, - }); - }, - - // Import participants from JSON with category creation - importParticipants: async (projectId, participantsData, options = {}) => { - try { - const now = new Date().toISOString(); - - // First, collect all unique category paths from participants - const categoryPaths = new Set(); - participantsData.forEach((participant) => { - if (participant.fullPath) { - categoryPaths.add(participant.fullPath); - } - }); - - // Import categories if needed - if (categoryPaths.size > 0) { - const categoriesData = Array.from(categoryPaths).map((fullPath) => ({ - name: fullPath.split('.').pop(), - fullPath, - })); - await useCategoryStore.getState().importCategories(projectId, categoriesData); - } - - // Load categories to get their IDs - const categories = await db.categories.where('projectId').equals(projectId).toArray(); - - // Build category path map - const categoryPathMap = new Map(); - categories.forEach((category) => { - const fullPath = useCategoryStore.getState().buildCategoryPath(category.id, categories); - categoryPathMap.set(fullPath, category.name); - }); - - const existingParticipants = await db.participants - .where('projectId') - .equals(projectId) - .toArray(); - - const getCategoryNameForImport = (participantData) => { - const fullPath = String(participantData?.fullPath || '').trim(); - if (fullPath) { - return ( - categoryPathMap.get(fullPath) || - fullPath.split('.').pop() || - participantData.category || - 'Unknown' - ); - } - return participantData.category || 'Unknown'; - }; - - // Create participants - const participantsToImport = participantsData.map((participantData) => { - const participantImageId = String(participantData?.participantImage || '').trim(); - const importedThumbnail = participantImageId - ? getImportedThumbnailById(options.thumbnailById, participantImageId) - : null; - - return { - id: uuidv4(), - name: participantData.name, - category: getCategoryNameForImport(participantData), - thumbnail: normalizeStoredThumbnail(importedThumbnail), - projectId, - createdAt: now, - modifiedAt: now, - }; - }); - - const existingNamesByRoot = new Map(); - existingParticipants.forEach((participant) => { - const rootId = getRootIdByCategoryName(participant.category, categories); - if (!rootId) return; - if (!existingNamesByRoot.has(rootId)) { - existingNamesByRoot.set(rootId, new Set()); - } - existingNamesByRoot.get(rootId).add(participant.name); - }); - - participantsToImport.forEach((participant) => { - const name = participant.name?.trim() || ''; - if (!name) { - throw new Error('Participant name is required'); - } - if (!/^[A-Za-z0-9]+$/.test(name)) { - throw new Error('Participant name must contain only letters and numbers'); - } - if (name.length > get().maxParticipantNameLength) { - throw new Error( - `Participant name must be ${get().maxParticipantNameLength} characters or fewer` - ); - } - const rootId = getRootIdByCategoryName(participant.category, categories); - if (rootId) { - if (!existingNamesByRoot.has(rootId)) { - existingNamesByRoot.set(rootId, new Set()); - } - const rootSet = existingNamesByRoot.get(rootId); - if (rootSet.has(name)) { - throw new Error('Participant name must be unique within its category tree'); - } - rootSet.add(name); - } - }); - - await assertParticipantMutationSyncBudget(projectId, [ - ...existingParticipants, - ...participantsToImport, - ]); - - await db.participants.bulkAdd(participantsToImport); - await get().loadParticipants(projectId); - useSyncStore.getState().schedulePush(projectId); - toast({ - variant: 'success', - title: 'Participants Imported', - description: `${participantsToImport.length} participants have been imported`, - }); - return participantsToImport; - } catch (error) { - console.error('Error importing participants:', error); - toast({ - variant: 'error', - title: 'Failed to Import Participants', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - // Export participants to JSON with category full paths - exportParticipants: async (projectId) => { - try { - const participants = await db.participants.where('projectId').equals(projectId).toArray(); - const categories = await db.categories.where('projectId').equals(projectId).toArray(); - - // Build category path map - const categoryPathMap = new Map(); - categories.forEach((category) => { - const fullPath = useCategoryStore.getState().buildCategoryPath(category.id, categories); - categoryPathMap.set(category.name, fullPath); - }); - - // Export participants with full category paths and image IDs - return participants.map((participant) => { - const fullPath = categoryPathMap.get(participant.category) || participant.category; - const hasThumbnail = Boolean(resolveParticipantThumbnailDataUrl(participant.thumbnail)); - return { - name: participant.name, - fullPath, - participantImage: hasThumbnail - ? buildParticipantImageId({ - participantName: participant.name, - categoryPath: fullPath, - }) - : null, - }; - }); - } catch (error) { - console.error('Error exporting participants:', error); - toast({ - variant: 'error', - title: 'Failed to Export Participants', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, - - exportParticipantsArchive: async (projectId) => { - try { - const JSZip = (await import('jszip')).default; - const participantsManifest = await get().exportParticipants(projectId); - const participants = await db.participants.where('projectId').equals(projectId).toArray(); - - const zip = new JSZip(); - zip.file('participants.json', JSON.stringify(participantsManifest, null, 2)); - const thumbnailsFolder = zip.folder('Thumbnails'); - const manifestByName = new Map(participantsManifest.map((entry) => [entry.name, entry])); - - for (const participant of participants) { - const manifest = manifestByName.get(participant.name); - const participantImage = String(manifest?.participantImage || '').trim(); - if (!participantImage) continue; - const blob = storedParticipantThumbnailToBlob(participant.thumbnail); - if (!blob) continue; - thumbnailsFolder.file(`${participantImage}.png`, blob); - } - - const blob = await zip.generateAsync({ type: 'blob' }); - return { - blob, - defaultFileName: `participants-${new Date().toISOString().split('T')[0]}.zip`, - }; - } catch (error) { - console.error('Error exporting participants archive:', error); - toast({ - variant: 'error', - title: 'Failed to Export Participants', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } - }, + participants: [], isLoading: false, maxParticipantNameLength: 16, + loadParticipants: (projectId, context) => loadDomainRecords(set, 'participants', projectId, context), + createParticipant: async (data) => { + const context = await getRepositoryContext(), now = new Date().toISOString(); let participant; + await changeDomainRecords({ context, projectId: data.projectId, set, table: 'participants', message: 'Participant Created', achievement: trackFirstParticipantCreated, transform: (snapshot) => { + participant = normalizeParticipant({ ...data, id: crypto.randomUUID(), thumbnail: normalizeThumbnail(data.thumbnail), createdAt: now, modifiedAt: now }, snapshot); + snapshot.participants.push(participant); + } }); + return participant; + }, + updateParticipant: async (id, updates) => { + const { context, record } = await findDomainRecord('participants', id); let participant; + await changeDomainRecords({ context, projectId: record.projectId, set, table: 'participants', message: 'Participant Updated', transform: (snapshot) => { + const current = snapshot.participants.find((entry) => entry.id === id); + if (!current) throw new Error('Participant was removed. Reload before editing.'); + const next = { ...current, ...updates, id, projectId: current.projectId, modifiedAt: new Date().toISOString() }; + if (updates.category !== undefined && updates.categoryId === undefined && updates.category !== current.category) delete next.categoryId; + next.thumbnail = normalizeThumbnail(next.thumbnail); + participant = normalizeParticipant(next, snapshot, id); + snapshot.participants = snapshot.participants.map((entry) => entry.id === id ? participant : entry); + } }); + return participant; + }, + deleteParticipant: async (id) => { + const { context, record } = await findDomainRecord('participants', id); + await changeDomainRecords({ context, projectId: record.projectId, set, table: 'participants', message: 'Participant Deleted', transform: (snapshot) => { + assertUnreferenced(snapshot, 'participants', record); snapshot.participants = snapshot.participants.filter((entry) => entry.id !== id); + } }); + }, + importParticipants: async (projectId, inputs, options = {}) => { + const context = options.context || await getRepositoryContext(); let participants; + await changeDomainRecords({ context, projectId, set, table: 'participants', message: 'Participants Imported', transform: (snapshot) => { + const paths = inputs.filter((input) => input.fullPath).map((input) => ({ fullPath: input.fullPath })); + snapshot.categories = prepareCategoryImport(projectId, snapshot.categories, paths).categories; + const byPath = new Map(snapshot.categories.map((category) => [buildCategoryPath(category.id, snapshot.categories), category.id])); + participants = []; + for (const input of inputs) { + const now = new Date().toISOString(); + const categoryId = input.fullPath ? byPath.get(input.fullPath) : input.categoryId; + const thumbnail = normalizeThumbnail(thumbnailFor(options.thumbnailById, input.participantImage) || input.thumbnail); + const participant = normalizeParticipant({ ...input, id: crypto.randomUUID(), projectId, categoryId, thumbnail, createdAt: input.createdAt || now, modifiedAt: now }, snapshot); + snapshot.participants.push(participant); participants.push(participant); + } + } }); + return participants; + }, + importParticipantsFromFile: async (projectId, file) => { + const context = await getRepositoryContext(); + if (!file) throw new Error('Missing import file'); + if (file.size > 25 * 1024 * 1024) throw new Error('Participant import exceeds 25 MiB'); + const isZip = /\.(zip|mnteapart)$/i.test(file.name || ''); + if (!isZip) { + const text = await file.text(); context.assertCurrent(); + if (new TextEncoder().encode(text).length > 5 * 1024 * 1024) throw new Error('Participant JSON exceeds 5 MiB'); + const parsed = JSON.parse(text); + return get().importParticipants(projectId, Array.isArray(parsed) ? parsed : [parsed], { context }); + } + const { readBoundedArchive } = await import('@/lib/persistence/projectArchive'); + const entries = await readBoundedArchive(file); context.assertCurrent(); + const jsonBytes = entries.get('participants.json'); + if (!jsonBytes) throw new Error('Missing participants.json'); + const parsed = JSON.parse(new TextDecoder().decode(jsonBytes)), inputs = Array.isArray(parsed) ? parsed : [parsed], thumbnailById = new Map(); + for (const input of inputs) { + if (!input.participantImage) continue; + const bytes = entries.get(`Thumbnails/${input.participantImage}.png`) || entries.get(`thumbnails/${input.participantImage}.png`); + if (!bytes) continue; + if (bytes.length > 1024 * 1024) throw new Error('Participant thumbnail exceeds 1 MiB'); + thumbnailById.set(input.participantImage, await blobToStoredParticipantThumbnail(new Blob([bytes], {type:'image/png'}))); context.assertCurrent(); + } + return get().importParticipants(projectId, inputs, { context, thumbnailById }); + }, + exportParticipants: async (projectId) => { + const context = await getRepositoryContext(); + await assertProjectReady(projectId, context); + const records = await readProjectRecords(projectId, context); + return participantManifest(records.participants, records.categories); + }, + exportParticipantsArchive: async (projectId) => { + const context = await getRepositoryContext(); + await assertProjectReady(projectId, context); + const records = await readProjectRecords(projectId, context); + const manifest = participantManifest(records.participants, records.categories); + const { default: JSZip } = await import('jszip'); context.assertCurrent(); + const zip = new JSZip(); zip.file('participants.json', JSON.stringify(manifest, null, 2)); + for (const participant of manifest) { + if (!participant.participantImage) continue; + const blob = storedParticipantThumbnailToBlob(participant.thumbnail); + if (blob) zip.file(`Thumbnails/${participant.participantImage}.png`, blob); + } + const blob = await zip.generateAsync({ type: 'blob' }); context.assertCurrent(); + const { readBoundedArchive } = await import('@/lib/persistence/projectArchive'); + await readBoundedArchive(blob); context.assertCurrent(); + return { blob, defaultFileName: `participants-${new Date().toISOString().split('T')[0]}.zip` }; + }, })); diff --git a/src/stores/projectStore.js b/src/stores/projectStore.js index 5979d35..4f7d4e6 100644 --- a/src/stores/projectStore.js +++ b/src/stores/projectStore.js @@ -1,13 +1,19 @@ +import { describeError, errorToast } from '@/lib/errorPresentation'; +import { exportProjectArchive, prepareArchiveImport } from '@/lib/persistence/projectArchive'; +import { buildProjectSnapshot } from '@/lib/sync/snapshot'; +import { prepareProjectCommit, commitPreparedProject, mutateProject } from '@/lib/persistence/projectRepository'; import { create } from 'zustand'; import { v4 as uuidv4 } from 'uuid'; -import { db } from '@/lib/db'; +import { getRepositoryContext } from '@/lib/db'; import { toast } from '@/components/ui/toaster'; -import { openContainingFolder, saveExportBlob } from '@/lib/export/exportFile'; +import { saveExportBlob } from '@/lib/export/exportFile'; import { useSyncStore } from '@/stores/syncStore'; import { useDialogueStore } from '@/stores/dialogueStore'; import { DEFAULT_LOCALE, - buildLocalizedEntriesFromNodes, + allocateDialogueLocalizationSlug, + prepareLocalizedNodesAndEntries, + validateLocalizedEntriesForDialogue, isValidLocaleTag, normalizeProjectLocalizationConfig, } from '@/lib/localization/stringTable'; @@ -15,63 +21,46 @@ import { trackExampleProjectCreated, trackFirstNonExampleProjectCreated, } from '@/lib/achievements/achievementTracker'; -import { - blobToStoredParticipantThumbnail, - buildParticipantImageId, - storedParticipantThumbnailToBlob, -} from '@/lib/participantThumbnails'; import { resolveOnboardingExampleTemplateFile } from '@/lib/onboarding/templateLoader'; -const MAX_PROJECT_IMPORT_ARCHIVE_BYTES = 25 * 1024 * 1024; -const MAX_PROJECT_IMPORT_ENTRY_COUNT = 1000; -const MAX_PROJECT_IMPORT_TEXT_BYTES = 5 * 1024 * 1024; -const MAX_PROJECT_IMPORT_DIALOGUE_FILES = 250; const ONBOARDING_EXAMPLE_PROJECT_NAME = 'OnboardingExample'; -function parseImportedJsonText({ label, value }) { - if (!value) return null; - const byteLength = new TextEncoder().encode(String(value)).length; - if (byteLength > MAX_PROJECT_IMPORT_TEXT_BYTES) { - throw new Error(`${label} exceeds import size limit`); - } - try { - return JSON.parse(value); - } catch (error) { - throw new Error(`Invalid JSON in ${label}`); - } -} - -async function seedProjectLocalizationDefaultLocale(projectId, defaultLocale) { - const dialogues = await db.dialogues.where('projectId').equals(projectId).toArray(); - const allExisting = await db.localizedStrings.where('projectId').equals(projectId).toArray(); - const existingByDialogue = new Map(); - - for (const entry of allExisting) { - const key = String(entry?.dialogueId || '').trim(); - if (!key) continue; - if (!existingByDialogue.has(key)) { - existingByDialogue.set(key, []); - } - existingByDialogue.get(key).push(entry); - } - - const toUpsert = []; +function seedProjectLocalizationDefaultLocale(snapshot, projectId, defaultLocale, previousDefaultLocale) { + const dialogues = snapshot.dialogues; + // Migrate original inline text using the OLD default before changing its meaning. for (const dialogue of dialogues) { - const nodes = await db.nodes.where('dialogueId').equals(dialogue.id).toArray(); - const entries = buildLocalizedEntriesFromNodes({ - projectId, - dialogueId: dialogue.id, - dialogueSlug: dialogue.localizationSlug || '', - nodes, - locale: defaultLocale, - existingEntries: existingByDialogue.get(dialogue.id) || [], - }); - toUpsert.push(...entries); + const nodes = snapshot.nodes.filter((node) => node.dialogueId === dialogue.id); + const projectEntries = snapshot.localizedStrings; + const existingEntries = projectEntries.filter((entry) => entry.dialogueId === dialogue.id); + const slug = allocateDialogueLocalizationSlug(dialogue, dialogues); + const prepared = prepareLocalizedNodesAndEntries({ projectId, dialogueId: dialogue.id, dialogueSlug: slug, nodes, locale: previousDefaultLocale, existingEntries }); + const validation = validateLocalizedEntriesForDialogue({ projectId, dialogueId: dialogue.id, dialogueSlug: slug, nodes: prepared.nodes, entries: prepared.entries, defaultLocale: previousDefaultLocale, projectEntries }); + if (!validation.valid) { + const error = new Error(`Repair localization before changing the default locale: ${validation.errors[0].type}`); + error.code = 'LOCALIZATION_REPAIR_REQUIRED'; + error.diagnostics = validation.errors; + throw error; + } + snapshot.nodes = snapshot.nodes.filter((node) => node.dialogueId !== dialogue.id).concat(prepared.nodes); + snapshot.localizedStrings = snapshot.localizedStrings.filter((entry) => entry.dialogueId !== dialogue.id).concat(prepared.entries); + dialogue.localizationVersion = 2; + dialogue.localizationSlug = slug; } - - if (toUpsert.length > 0) { - await db.localizedStrings.bulkPut(toUpsert); + const entries = snapshot.localizedStrings; + const updates = []; + for (const entry of entries) { + const values = entry.values || {}; + if (Object.prototype.hasOwnProperty.call(values, defaultLocale)) continue; + if (!Object.prototype.hasOwnProperty.call(values, previousDefaultLocale)) { + const error = new Error(`Missing source translation for ${entry.key}; repair this entry before changing the default locale.`); + error.code = 'LOCALIZATION_REPAIR_REQUIRED'; + error.diagnostics = [{ type: 'missing_default_locale_value', key: entry.key, locale: previousDefaultLocale }]; + throw error; + } + updates.push({ ...entry, values: { ...values, [defaultLocale]: values[previousDefaultLocale] }, modifiedAt: new Date().toISOString() }); } + const byKey = new Map(updates.map((entry) => [entry.key, entry])); + snapshot.localizedStrings = snapshot.localizedStrings.map((entry) => byKey.get(entry.key) || entry); } function isOnboardingExampleProject(project) { @@ -79,6 +68,8 @@ function isOnboardingExampleProject(project) { return Boolean(project.isExample) || String(project.name || '').trim() === ONBOARDING_EXAMPLE_PROJECT_NAME; } +const loadRequests = {}; + /** * Project Store * Manages projects state and CRUD operations @@ -93,14 +84,17 @@ export const useProjectStore = create((set, get) => ({ * Load all projects from IndexedDB */ loadProjects: async () => { + const request = loadRequests.loadProjects = (loadRequests.loadProjects || 0) + 1; set({ isLoading: true, error: null }); try { - const projects = await db.projects.toArray(); + const context = await getRepositoryContext(); + const database = context.db; + const projects = await database.projects.toArray(); // Load dialogue counts for each project const projectsWithCounts = await Promise.all( projects.map(async (project) => { - const dialogueCount = await db.dialogues + const dialogueCount = await database.dialogues .where('projectId') .equals(project.id) .count(); @@ -112,15 +106,15 @@ export const useProjectStore = create((set, get) => ({ }) ); + context.assertCurrent(); + if (request !== loadRequests.loadProjects) return; set({ projects: projectsWithCounts, isLoading: false }); } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; + if (request !== loadRequests.loadProjects) return; console.error('Error loading projects:', error); - toast({ - variant: 'error', - title: 'Failed to Load Projects', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'load')); + set({ error: describeError(error), isLoading: false }); } }, @@ -133,11 +127,10 @@ export const useProjectStore = create((set, get) => ({ const now = new Date().toISOString(); const id = uuidv4(); const newProject = { - id, ...projectData, + id, localization: normalizeProjectLocalizationConfig( projectData?.localization || { - enabled: false, defaultLocale: DEFAULT_LOCALE, supportedLocales: [DEFAULT_LOCALE], } @@ -146,7 +139,9 @@ export const useProjectStore = create((set, get) => ({ createdAt: now, modifiedAt: now, }; - await db.projects.add(newProject); + const context = await getRepositoryContext(); + await commitPreparedProject(await prepareProjectCommit({ project: newProject }, { context, expectedSequence: 0, operation: 'create' })); + context.assertCurrent(); try { await trackFirstNonExampleProjectCreated(); } catch (error) { @@ -161,13 +156,10 @@ export const useProjectStore = create((set, get) => ({ }); return newProject; } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; console.error('Error creating project:', error); - toast({ - variant: 'error', - title: 'Failed to Create Project', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'create')); + set({ error: describeError(error), isLoading: false }); throw error; } }, @@ -178,16 +170,20 @@ export const useProjectStore = create((set, get) => ({ createOnboardingExampleProject: async () => { set({ isLoading: true, error: null }); try { - const projects = await db.projects.toArray(); + const context = await getRepositoryContext(); + const database = context.db; + const projects = await database.projects.toArray(); const existingExampleProject = projects.find(isOnboardingExampleProject); if (existingExampleProject) { throw new Error('Example graph already exists. Delete the previous example project first.'); } const template = await resolveOnboardingExampleTemplateFile(); + context.assertCurrent(); let importedResult = null; const projectId = await get().importProject(template.file, { isExample: true, + context, source: template.source, onImported: (payload) => { importedResult = payload; @@ -198,67 +194,42 @@ export const useProjectStore = create((set, get) => ({ } const dialogueId = String(importedResult?.firstDialogueId || '').trim() || - String((await db.dialogues.where('projectId').equals(projectId).first())?.id || '').trim(); + String((await database.dialogues.where('projectId').equals(projectId).first())?.id || '').trim(); return { projectId, dialogueId }; } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; console.error('Error creating onboarding example project:', error); - toast({ - variant: 'error', - title: 'Failed to Create Example Project', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'create')); + set({ error: describeError(error), isLoading: false }); throw error; } }, /** * Update an existing project */ - updateProject: async (id, updates) => { + updateProject: async (id, updates, options = {}) => { set({ isLoading: true, error: null }); try { - const existingProject = await db.projects.get(id); - if (!existingProject) { - throw new Error('Project not found'); - } - - const previousLocalization = normalizeProjectLocalizationConfig( - existingProject.localization || {} - ); - const nextLocalization = - updates && Object.prototype.hasOwnProperty.call(updates, 'localization') - ? normalizeProjectLocalizationConfig(updates.localization || {}) - : previousLocalization; - - const nextPayload = { - ...updates, - modifiedAt: new Date().toISOString(), - }; - - if (Object.prototype.hasOwnProperty.call(nextPayload, 'localization')) { - nextPayload.localization = nextLocalization; - } - - await db.projects.update(id, nextPayload); - - if (previousLocalization.defaultLocale !== nextLocalization.defaultLocale) { - await seedProjectLocalizationDefaultLocale(id, nextLocalization.defaultLocale); - } - await get().loadProjects(); + const context = options.repositoryContext || await getRepositoryContext(); + const database = context.db; + context.assertCurrent(); + await mutateProject(id, { context, transform: (snapshot) => { + const previous = normalizeProjectLocalizationConfig(snapshot.project.localization || {}); + const next = Object.prototype.hasOwnProperty.call(updates || {}, 'localization') ? normalizeProjectLocalizationConfig(updates.localization || {}) : previous; + if (previous.defaultLocale !== next.defaultLocale) seedProjectLocalizationDefaultLocale(snapshot, id, next.defaultLocale, previous.defaultLocale); + snapshot.project = { ...snapshot.project, ...updates, id, localization: next, modifiedAt: new Date().toISOString() }; + } }); + context.assertCurrent(); + const projects = await database.projects.toArray(); + context.assertCurrent(); + set((state) => ({ projects: projects.map((project) => ({ ...state.projects.find((existing) => existing.id === project.id), ...project })), isLoading: false })); useSyncStore.getState().schedulePush(id); - toast({ - variant: 'success', - title: 'Project Updated', - description: 'Project has been updated successfully', - }); + toast({ variant: 'success', title: 'Project Updated', description: 'Project has been updated successfully' }); } catch (error) { - console.error('Error updating project:', error); - toast({ - variant: 'error', - title: 'Failed to Update Project', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + if (error.code !== 'STALE_PROFILE') { + toast(errorToast(error, 'update')); + set({ error: describeError(error), isLoading: false }); + } throw error; } }, @@ -266,7 +237,9 @@ export const useProjectStore = create((set, get) => ({ updateProjectLocalization: async (id, localizationUpdates = {}) => { set({ isLoading: true, error: null }); try { - const project = await db.projects.get(id); + const context = await getRepositoryContext(); + const project = await context.db.projects.get(id); + context.assertCurrent(); if (!project) { throw new Error('Project not found'); } @@ -291,17 +264,15 @@ export const useProjectStore = create((set, get) => ({ await get().updateProject(id, { localization: nextLocalization, - }); + }, { repositoryContext: context }); + context.assertCurrent(); return nextLocalization; } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; console.error('Error updating project localization:', error); - toast({ - variant: 'error', - title: 'Failed to Update Localization', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'update')); + set({ error: describeError(error), isLoading: false }); throw error; } }, @@ -312,48 +283,20 @@ export const useProjectStore = create((set, get) => ({ deleteProject: async (id) => { set({ isLoading: true, error: null }); try { - const syncedProviders = Array.from( - new Set( - (await db.syncProjects.where('projectId').equals(id).toArray()) - .map((entry) => String(entry?.provider || '').trim()) - .filter(Boolean) - ) - ); - await db.transaction('rw', [db.projects, db.dialogues, db.participants, db.categories, db.decorators, db.conditions, db.localizedStrings, db.nodes, db.edges], async () => { - const projectDialogues = await db.dialogues.where('projectId').equals(id).toArray(); - const dialogueIds = projectDialogues.map((dialogue) => dialogue.id); - - if (dialogueIds.length > 0) { - await db.nodes.where('dialogueId').anyOf(dialogueIds).delete(); - await db.edges.where('dialogueId').anyOf(dialogueIds).delete(); - } - - await db.projects.delete(id); - await db.dialogues.where('projectId').equals(id).delete(); - await db.participants.where('projectId').equals(id).delete(); - await db.categories.where('projectId').equals(id).delete(); - await db.decorators.where('projectId').equals(id).delete(); - await db.conditions.where('projectId').equals(id).delete(); - await db.localizedStrings.where('projectId').equals(id).delete(); - }); - await get().loadProjects(); - await useDialogueStore.getState().loadDialogues(); - await useSyncStore.getState().scheduleProjectDeletion(id, { - providers: syncedProviders, - }); + const context = await getRepositoryContext(); + await mutateProject(id, { context, operation: 'delete' }); context.assertCurrent(); + await get().loadProjects(); context.assertCurrent(); + await useDialogueStore.getState().loadDialogues(); context.assertCurrent(); toast({ variant: 'success', title: 'Project Deleted', description: 'Project and all related data have been deleted', }); } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; console.error('Error deleting project:', error); - toast({ - variant: 'error', - title: 'Failed to Delete Project', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'delete')); + set({ error: describeError(error), isLoading: false }); throw error; } }, @@ -362,9 +305,14 @@ export const useProjectStore = create((set, get) => ({ * Set the current active project */ setCurrentProject: async (id) => { + const request = loadRequests.setCurrentProject = (loadRequests.setCurrentProject || 0) + 1; set({ isLoading: true, error: null }); try { - const project = await db.projects.get(id); + const context = await getRepositoryContext(); + const database = context.db; + const project = await database.projects.get(id); + context.assertCurrent(); + if (request !== loadRequests.setCurrentProject) return; set({ currentProject: project ? { @@ -375,13 +323,11 @@ export const useProjectStore = create((set, get) => ({ isLoading: false, }); } catch (error) { + if (error.code === 'STALE_PROFILE') throw error; + if (request !== loadRequests.setCurrentProject) return; console.error('Error setting current project:', error); - toast({ - variant: 'error', - title: 'Failed to Load Project', - description: error.message || 'An unexpected error occurred', - }); - set({ error: error.message, isLoading: false }); + toast(errorToast(error, 'load')); + set({ error: describeError(error), isLoading: false }); throw error; } }, @@ -397,543 +343,31 @@ export const useProjectStore = create((set, get) => ({ * Export entire project with all dialogues */ exportProject: async (projectId) => { + const context = await getRepositoryContext(); try { - // Load JSZip - const JSZip = (await import('jszip')).default; - - // Get project data - const project = await db.projects.get(projectId); - if (!project) { - throw new Error('Project not found'); - } - - // Get all dialogues for this project - const dialogues = await db.dialogues - .where('projectId') - .equals(projectId) - .toArray(); - - // Get categories, participants, decorators, and conditions - const categories = await db.categories - .where('projectId') - .equals(projectId) - .toArray(); - - const participants = await db.participants - .where('projectId') - .equals(projectId) - .toArray(); - - const decorators = await db.decorators - .where('projectId') - .equals(projectId) - .toArray(); - const conditions = await db.conditions - .where('projectId') - .equals(projectId) - .toArray(); - const dialogueIds = dialogues.map((dialogue) => dialogue.id).filter(Boolean); - const nodes = dialogueIds.length > 0 - ? await db.nodes.where('dialogueId').anyOf(dialogueIds).toArray() - : []; - const edges = dialogueIds.length > 0 - ? await db.edges.where('dialogueId').anyOf(dialogueIds).toArray() - : []; - - // Build category path map - const { useCategoryStore } = await import('./categoryStore'); - const categoryPathMap = new Map(); - categories.forEach((cat) => { - const fullPath = useCategoryStore.getState().buildCategoryPath(cat.id, categories); - categoryPathMap.set(cat.name, fullPath); - }); - - // Prepare project data - const projectData = { - projectGuid: project.id, - projectName: project.name, - projectDescription: project.description || '', - version: project.version || '1.0.0', - localization: normalizeProjectLocalizationConfig(project.localization || {}), - createdAt: project.createdAt, - modifiedAt: project.modifiedAt || new Date().toISOString(), - }; - - // Export categories with fullPath - const categoriesExport = categories.map((cat) => ({ - name: cat.name, - fullPath: categoryPathMap.get(cat.name) || cat.name, - })); - - // Export participants with fullPath - const participantsExport = participants.map((p) => ({ - name: p.name, - fullPath: categoryPathMap.get(p.category) || p.category, - participantImage: p.thumbnail - ? buildParticipantImageId({ - participantName: p.name, - categoryPath: categoryPathMap.get(p.category) || p.category, - }) - : null, - })); - - const usedDecoratorIds = new Set(); - const usedDecoratorNames = new Set(); - const usedConditionIds = new Set(); - const usedConditionNames = new Set(); - - nodes.forEach((node) => { - const decoratorInstances = Array.isArray(node?.data?.decorators) ? node.data.decorators : []; - decoratorInstances.forEach((decorator) => { - const decoratorId = String(decorator?.id || '').trim(); - const decoratorName = String(decorator?.name || '').trim(); - if (decoratorId) usedDecoratorIds.add(decoratorId); - if (decoratorName) usedDecoratorNames.add(decoratorName); - }); - }); - - edges.forEach((edge) => { - const rules = Array.isArray(edge?.data?.conditions?.rules) ? edge.data.conditions.rules : []; - rules.forEach((rule) => { - const conditionId = String(rule?.id || '').trim(); - const conditionName = String(rule?.name || '').trim(); - if (conditionId) usedConditionIds.add(conditionId); - if (conditionName) usedConditionNames.add(conditionName); - }); - }); - - // Export used decorator/condition definitions only (retain GUID/id). - const exportWithoutMeta = (entry) => { - const exported = { ...entry }; - delete exported.projectId; - delete exported.createdAt; - delete exported.modifiedAt; - return exported; - }; - const decoratorsExport = decorators - .filter((definition) => { - const definitionId = String(definition?.id || '').trim(); - const definitionName = String(definition?.name || '').trim(); - return usedDecoratorIds.has(definitionId) || usedDecoratorNames.has(definitionName); - }) - .map(exportWithoutMeta); - const conditionsExport = conditions - .filter((definition) => { - const definitionId = String(definition?.id || '').trim(); - const definitionName = String(definition?.name || '').trim(); - return usedConditionIds.has(definitionId) || usedConditionNames.has(definitionName); - }) - .map(exportWithoutMeta); - - // Create main project ZIP - const projectZip = new JSZip(); - - // Add project JSON files - projectZip.file('projectData.json', JSON.stringify(projectData, null, 2)); - projectZip.file('categories.json', JSON.stringify(categoriesExport, null, 2)); - projectZip.file('participants.json', JSON.stringify(participantsExport, null, 2)); - projectZip.file('decorators.json', JSON.stringify(decoratorsExport, null, 2)); - projectZip.file('conditions.json', JSON.stringify(conditionsExport, null, 2)); - const thumbnailsFolder = projectZip.folder('Thumbnails'); - participantsExport.forEach((entry, index) => { - const participantImageId = String(entry?.participantImage || '').trim(); - if (!participantImageId) return; - const participant = participants[index]; - try { - const thumbnailBlob = storedParticipantThumbnailToBlob(participant?.thumbnail); - if (!thumbnailBlob) return; - thumbnailsFolder.file(`${participantImageId}.png`, thumbnailBlob); - } catch (error) { - console.warn( - `Skipping invalid participant thumbnail for ${participant?.name || participantImageId}` - ); - } - }); - - // Create dialogues folder - const dialoguesFolder = projectZip.folder('dialogues'); - - // Export each dialogue as a .mnteadlg file - const { useDialogueStore } = await import('./dialogueStore'); - const dialogueStore = useDialogueStore.getState(); - - for (const dialogue of dialogues) { - try { - console.log(`Exporting dialogue: ${dialogue.name} (${dialogue.id})`); - - // Generate nested dialogue export blob without thumbnails. - // Project-level archive already contains all participant thumbnails. - const dialogueBlob = await dialogueStore.exportDialogueAsBlob(dialogue.id, { - includeThumbnails: false, - }); - - // Add to dialogues folder with sanitized name - const sanitizedName = dialogue.name.replace(/[^a-z0-9]/gi, '_'); - dialoguesFolder.file(`${sanitizedName}.mnteadlg`, dialogueBlob); - console.log(`Successfully exported dialogue: ${dialogue.name}`); - } catch (error) { - console.error(`Failed to export dialogue ${dialogue.name}:`, error); - throw new Error( - `Failed to export dialogue "${dialogue.name}": ${error.message || 'Unknown error'}` - ); - } - } - - // Generate the final ZIP - const blob = await projectZip.generateAsync({ type: 'blob' }); - const defaultFileName = `${project.name}.mnteadlgproj`; - const saveResult = await saveExportBlob({ - blob, - defaultFileName, - filters: [{ name: 'Project Export', extensions: ['mnteadlgproj'] }], - }); - if (saveResult.canceled) { - return; - } - - if (saveResult.filePath) { - await db.projects.update(projectId, { - lastExportPath: saveResult.filePath, - }); - set((state) => ({ - projects: state.projects.map((entry) => - entry.id === projectId - ? { ...entry, lastExportPath: saveResult.filePath } - : entry - ), - currentProject: - state.currentProject?.id === projectId - ? { ...state.currentProject, lastExportPath: saveResult.filePath } - : state.currentProject, - })); - } - - toast({ - variant: 'success', - title: 'Project Exported', - description: `${defaultFileName} has been exported`, - action: saveResult.filePath - ? { - label: 'Open Folder', - onClick: () => { - void openContainingFolder(saveResult.filePath); - }, - } - : undefined, - duration: saveResult.filePath ? 8000 : 3000, - }); - } catch (error) { - console.error('Error exporting project:', error); - toast({ - variant: 'error', - title: 'Failed to Export Project', - description: error.message || 'An unexpected error occurred', - }); - throw error; - } + const snapshot = await buildProjectSnapshot(projectId, { context, requireReady: true }); + const blob = await exportProjectArchive(snapshot); + context.assertCurrent(); + const saved = await saveExportBlob({ blob, defaultFileName: `${snapshot.project.name}.mnteadlgproj`, filters: [{ name: 'Project Export', extensions: ['mnteadlgproj'] }] }); + context.assertCurrent(); + if (saved.canceled) return; + if (saved.filePath) await context.db.projects.update(projectId, { lastExportPath: saved.filePath }); + toast({ variant: 'success', title: 'Project Exported', description: snapshot.project.name }); + return blob; + } catch (error) { if (error.code !== 'STALE_PROFILE') toast(errorToast(error, 'export')); throw error; } }, - importProject: async (file, options = {}) => { + const context = options.context || await getRepositoryContext(); + context.assertCurrent(); try { - const importOptions = options && typeof options === 'object' ? options : {}; - if (!file) { - throw new Error('Missing import file'); - } - if (file.size > MAX_PROJECT_IMPORT_ARCHIVE_BYTES) { - throw new Error( - `Project archive is too large (max ${Math.floor( - MAX_PROJECT_IMPORT_ARCHIVE_BYTES / (1024 * 1024) - )} MB)` - ); - } - - const JSZip = (await import('jszip')).default; - const zip = await JSZip.loadAsync(file); - const allZipEntries = Object.values(zip.files || {}); - const fileEntries = allZipEntries.filter((entry) => entry && !entry.dir); - if (fileEntries.length > MAX_PROJECT_IMPORT_ENTRY_COUNT) { - throw new Error('Project archive contains too many files'); - } - for (const entry of fileEntries) { - const safePath = String(entry?.name || ''); - if (safePath.startsWith('/') || safePath.includes('..')) { - throw new Error(`Unsafe path in project archive: ${safePath}`); - } - } - - const projectDataStr = await zip.file('projectData.json')?.async('text'); - const participantsStr = await zip.file('participants.json')?.async('text'); - const categoriesStr = await zip.file('categories.json')?.async('text'); - const decoratorsStr = await zip.file('decorators.json')?.async('text'); - const conditionsStr = await zip.file('conditions.json')?.async('text'); - - if (!projectDataStr) { - throw new Error('Invalid project file: missing projectData.json'); - } - - const projectData = parseImportedJsonText({ - label: 'projectData.json', - value: projectDataStr, - }); - if (!projectData || typeof projectData !== 'object' || Array.isArray(projectData)) { - throw new Error('Invalid project metadata in projectData.json'); - } - const participants = - parseImportedJsonText({ - label: 'participants.json', - value: participantsStr, - }) || []; - const categories = - parseImportedJsonText({ - label: 'categories.json', - value: categoriesStr, - }) || []; - const decorators = - parseImportedJsonText({ - label: 'decorators.json', - value: decoratorsStr, - }) || []; - const conditions = - parseImportedJsonText({ - label: 'conditions.json', - value: conditionsStr, - }) || []; - if ( - !Array.isArray(participants) || - !Array.isArray(categories) || - !Array.isArray(decorators) || - !Array.isArray(conditions) - ) { - throw new Error('Imported project metadata must contain arrays'); - } - - const { v4: uuidv4 } = await import('uuid'); - const importedProjectGuid = String(projectData?.projectGuid || projectData?.id || '').trim(); - const newProjectId = importedProjectGuid || uuidv4(); - const now = new Date().toISOString(); - const existingProject = await db.projects.get(newProjectId); - - if (existingProject) { - await db.transaction( - 'rw', - [ - db.dialogues, - db.nodes, - db.edges, - db.participants, - db.categories, - db.decorators, - db.conditions, - db.localizedStrings, - ], - async () => { - const existingDialogues = await db.dialogues.where('projectId').equals(newProjectId).toArray(); - const dialogueIds = existingDialogues - .map((dialogue) => String(dialogue?.id || '').trim()) - .filter(Boolean); - - if (dialogueIds.length > 0) { - await db.nodes.where('dialogueId').anyOf(dialogueIds).delete(); - await db.edges.where('dialogueId').anyOf(dialogueIds).delete(); - } - - await db.dialogues.where('projectId').equals(newProjectId).delete(); - await db.participants.where('projectId').equals(newProjectId).delete(); - await db.categories.where('projectId').equals(newProjectId).delete(); - await db.decorators.where('projectId').equals(newProjectId).delete(); - await db.conditions.where('projectId').equals(newProjectId).delete(); - await db.localizedStrings.where('projectId').equals(newProjectId).delete(); - } - ); - } - - const newProject = { - id: newProjectId, - name: projectData.projectName, - description: projectData.projectDescription || projectData.description || '', - version: projectData.version || '1.0.0', - localization: normalizeProjectLocalizationConfig(projectData.localization || {}), - isExample: Boolean(importOptions.isExample), - importSource: String(importOptions.source || 'manual-import'), - createdAt: projectData.createdAt || existingProject?.createdAt || now, - modifiedAt: now, - }; - - await db.projects.put(newProject); - - // Build category hierarchy from fullPath (e.g. "NPC.Merchant" → NPC > Merchant) - const existingCategoryPaths = new Map(); // fullPath → { id, name } - - for (const cat of categories) { - const fullPath = String(cat?.fullPath || cat?.name || '').trim(); - if (!fullPath) continue; - const pathParts = fullPath.split('.'); - let parentId = null; - let currentPath = ''; - - for (const part of pathParts) { - currentPath = currentPath ? `${currentPath}.${part}` : part; - if (!existingCategoryPaths.has(currentPath)) { - const newCatId = uuidv4(); - await db.categories.add({ - id: newCatId, - name: part, - parentCategoryId: parentId, - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - }); - existingCategoryPaths.set(currentPath, { id: newCatId, name: part }); - parentId = newCatId; - } else { - parentId = existingCategoryPaths.get(currentPath).id; - } - } - } - - // Build path-to-leaf-name map for participant category resolution - const importedCategoryPathToName = new Map(); - for (const [path, entry] of existingCategoryPaths) { - importedCategoryPathToName.set(path, entry.name); - } - - for (const part of participants) { - const participantImageId = String(part?.participantImage || '').trim(); - let thumbnail = null; - if (participantImageId) { - const thumbnailEntry = - zip.file(`Thumbnails/${participantImageId}.png`) || - zip.file(`thumbnails/${participantImageId}.png`); - if (thumbnailEntry) { - try { - const thumbnailBlob = await thumbnailEntry.async('blob'); - thumbnail = await blobToStoredParticipantThumbnail(thumbnailBlob); - } catch (error) { - console.warn( - `Skipping invalid participant thumbnail in project import (${participantImageId})` - ); - } - } - } - - const fullPath = String(part?.fullPath || '').trim(); - const categoryName = - importedCategoryPathToName.get(fullPath) || - String(part?.category || '').trim() || - (fullPath ? fullPath.split('.').pop() : 'Unknown'); - - const newPartId = uuidv4(); - await db.participants.add({ - id: newPartId, - name: part.name, - category: categoryName || 'Unknown', - thumbnail, - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - }); - } - - for (const dec of decorators) { - const newDecId = uuidv4(); - await db.decorators.add({ - id: newDecId, - name: dec.name, - type: dec.type, - values: dec.values || {}, - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - }); - } - - for (const cond of conditions) { - const newConditionId = uuidv4(); - await db.conditions.add({ - id: newConditionId, - name: cond.name, - type: cond.type, - properties: cond.properties || [], - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - }); - } - - const { useDialogueStore } = await import('./dialogueStore'); - const dialogueStore = useDialogueStore.getState(); - let firstDialogueId = null; - - const dialoguesFolder = zip.folder('dialogues'); - if (dialoguesFolder) { - const dialogueFiles = []; - dialoguesFolder.forEach((relativePath, file) => { - if (relativePath.endsWith('.mnteadlg') && !file.dir) { - dialogueFiles.push({ path: relativePath, file }); - } - }); - if (dialogueFiles.length > MAX_PROJECT_IMPORT_DIALOGUE_FILES) { - throw new Error('Project archive contains too many dialogue files'); - } - - for (const { path, file } of dialogueFiles) { - try { - if (String(path || '').startsWith('/') || String(path || '').includes('..')) { - throw new Error('Unsafe dialogue archive path'); - } - const dialogueBlob = await file.async('blob'); - const dialogueFile = new File([dialogueBlob], path); - const importedDialogue = await dialogueStore.importDialogue(newProjectId, dialogueFile); - if (!firstDialogueId) { - firstDialogueId = String(importedDialogue?.id || '').trim() || null; - } - } catch (error) { - console.error(`Failed to import dialogue ${path}:`, error); - toast({ - variant: 'error', - title: 'Failed to Import Dialogue', - description: `${path}: ${error.message}`, - }); - } - } - } - - await get().loadProjects(); - - if (importOptions.isExample) { - try { - await trackExampleProjectCreated(); - } catch (error) { - console.warn('[achievements] Failed to track example project:', error); - } - } - - useSyncStore.getState().schedulePush(newProjectId); - - if (typeof importOptions.onImported === 'function') { - importOptions.onImported({ - projectId: newProjectId, - firstDialogueId, - source: importOptions.source || 'manual-import', - isExample: Boolean(importOptions.isExample), - }); - } - - toast({ - variant: 'success', - title: 'Project Imported', - description: `${newProject.name} has been imported successfully`, - }); - - return newProjectId; - } catch (error) { - console.error('Error importing project:', error); - toast({ - variant: 'error', - title: 'Failed to Import Project', - description: error.message || 'An unexpected error occurred', - }); - } + const imported = await prepareArchiveImport(file, { ...options, context }); + await commitPreparedProject(imported.prepared); context.assertCurrent(); + await get().loadProjects(); context.assertCurrent(); + if (options.isExample) await trackExampleProjectCreated(); + options.onImported?.({ projectId: imported.projectId, firstDialogueId: imported.firstDialogueId, source: options.source || 'manual-import', isExample: Boolean(options.isExample) }); + useSyncStore.getState().schedulePush(imported.projectId); + toast({ variant: 'success', title: 'Project Imported', description: imported.prepared.revision.snapshot.project.name }); + return imported.projectId; + } catch (error) { if (error.code !== 'STALE_PROFILE') toast(errorToast(error, 'import')); throw error; } }, })); - From 7787e1a390299340c21bc582694072b845f014ce Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:22 +0200 Subject: [PATCH 12/22] U: Rework sync engine around immutable revisions Add revision protocol and credential store; update snapshots, providers, Google Drive and Steam clients, sync actions and sync store. --- src/lib/sync/core/providerCatalog.js | 98 +-- src/lib/sync/core/revisionProtocol.js | 364 ++++++++ src/lib/sync/core/syncActions.js | 812 +---------------- src/lib/sync/credentialStore.js | 79 ++ src/lib/sync/crypto.js | 4 +- src/lib/sync/googleDriveAuth.js | 54 +- src/lib/sync/googleDriveClient.js | 59 +- src/lib/sync/providers/storageProviders.js | 12 +- src/lib/sync/snapshot.js | 354 +------- src/lib/sync/steamCloudClient.js | 49 +- src/lib/sync/syncStorage.js | 95 +- src/stores/syncStore.js | 964 +++------------------ 12 files changed, 763 insertions(+), 2181 deletions(-) create mode 100644 src/lib/sync/core/revisionProtocol.js create mode 100644 src/lib/sync/credentialStore.js diff --git a/src/lib/sync/core/providerCatalog.js b/src/lib/sync/core/providerCatalog.js index 0c0b190..59606cd 100644 --- a/src/lib/sync/core/providerCatalog.js +++ b/src/lib/sync/core/providerCatalog.js @@ -1,6 +1,4 @@ -import { getActiveProfileId } from '@/lib/profile/activeProfile'; import { - MIME_TYPE, SYNC_CATALOG_FILE_NAME, SYNC_CATALOG_SCHEMA_VERSION, } from '@/lib/sync/core/constants'; @@ -124,12 +122,8 @@ export async function readProviderCatalog(provider) { } const rawText = await storage.downloadFile(remoteFile.id); - let parsed = null; - try { - parsed = JSON.parse(rawText); - } catch (error) { - parsed = null; - } + const parsed = JSON.parse(rawText); + if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed?.objects?.projects) || !Array.isArray(parsed?.objects?.dialogues) || !Array.isArray(parsed?.tombstones)) throw new Error('Corrupt or unsupported legacy catalogue.'); return { providerId, @@ -150,59 +144,8 @@ export async function readProviderCatalog(provider) { } } -export async function writeProviderCatalog(provider, catalog, options = {}) { - const { providerId, storage } = getSyncContext({ provider }); - const nowIso = new Date().toISOString(); - const normalized = normalizeProviderCatalog(catalog); - const nextCatalog = { - ...normalized, - catalogRevision: - options?.bumpRevision === false - ? toRevision(normalized.catalogRevision) - : toRevision(normalized.catalogRevision) + 1, - updatedAt: nowIso, - }; - - const appProperties = { - schemaVersion: String(SYNC_CATALOG_SCHEMA_VERSION), - catalogRevision: String(nextCatalog.catalogRevision), - profileId: String(getActiveProfileId() || 'local'), - updatedAt: nowIso, - kind: 'mountea-sync-catalog', - }; - - const content = JSON.stringify(stripCatalogTransientFields(nextCatalog)); - let fileId = String(options?.fileId || '').trim(); - if (!fileId) { - const existing = await storage.findFileByName(SYNC_CATALOG_FILE_NAME); - fileId = String(existing?.id || '').trim(); - } - - if (fileId) { - const result = await storage.updateFile({ - fileId, - content, - mimeType: MIME_TYPE, - appProperties, - }); - return { - providerId, - fileId: String(result?.id || fileId), - catalog: nextCatalog, - }; - } - - const result = await storage.createFile({ - name: SYNC_CATALOG_FILE_NAME, - content, - mimeType: MIME_TYPE, - appProperties, - }); - return { - providerId, - fileId: String(result?.id || ''), - catalog: nextCatalog, - }; +export async function writeProviderCatalog() { + throw new Error('Legacy catalogues are read-only. Publish immutable revisions instead.'); } function compareByUpdatedAtThenRevision(current, candidate) { @@ -239,7 +182,6 @@ function compareByDeletedAtThenExpiry(current, candidate) { export function mergeProviderCatalogs(catalogEntries = []) { const nowIso = new Date().toISOString(); - const nowMs = Date.now(); const projectMap = new Map(); const dialogueMap = new Map(); const tombstoneMap = new Map(); @@ -289,37 +231,7 @@ export function mergeProviderCatalogs(catalogEntries = []) { } } - const activeTombstones = Array.from(tombstoneMap.values()).filter((item) => { - const expiresAtMs = item?.expiresAt ? Date.parse(item.expiresAt) : 0; - return !(expiresAtMs && expiresAtMs <= nowMs); - }); - - const projectTombstones = new Set( - activeTombstones - .filter((item) => item.entityType === 'project') - .map((item) => String(item.entityId || '').trim()) - .filter(Boolean) - ); - const dialogueTombstones = new Set( - activeTombstones - .filter((item) => item.entityType === 'dialogue') - .map((item) => String(item.entityId || '').trim()) - .filter(Boolean) - ); - - for (const projectId of projectTombstones) { - projectMap.delete(projectId); - } - - for (const dialogueId of dialogueTombstones) { - dialogueMap.delete(dialogueId); - } - - for (const [dialogueId, dialogue] of dialogueMap.entries()) { - if (projectTombstones.has(String(dialogue?.projectId || '').trim())) { - dialogueMap.delete(dialogueId); - } - } + const activeTombstones = Array.from(tombstoneMap.values()); return { schemaVersion: SYNC_CATALOG_SCHEMA_VERSION, diff --git a/src/lib/sync/core/revisionProtocol.js b/src/lib/sync/core/revisionProtocol.js new file mode 100644 index 0000000..de96302 --- /dev/null +++ b/src/lib/sync/core/revisionProtocol.js @@ -0,0 +1,364 @@ +import { getRepositoryContext, assertProjectReady } from '@/lib/db'; +import { readProjectState, mutateProject, applyRemoteRevision, prepareProjectCommit, commitPreparedProject } from '@/lib/persistence/projectRepository'; +import { canonicalizeProject, hashProject } from '@/lib/persistence/canonicalProject'; +import { applyProjectSnapshotAsNew } from '@/lib/sync/snapshot'; +import { encryptPayload, decryptPayload } from '@/lib/sync/crypto'; +import { getSyncContext } from './providerGateway'; +import { MAX_SYNC_PAYLOAD_BYTES, SYNC_CATALOG_FILE_NAME } from './constants'; + +export const REVISION_PREFIX = 'mountea-revision-v2--'; +const DELETION_PREFIX = 'mountea-deletion-v2--'; +const locks = new WeakMap(); +const now = () => new Date().toISOString(); +const error = (code, message) => Object.assign(new Error(message), { code }); +export const revisionFileName = async (projectId, revisionId) => { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([projectId, revisionId]))); + return `${REVISION_PREFIX}${[...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')}.json`; +}; +const ordered = value => Array.isArray(value) ? value.map(ordered) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, ordered(value[key])])) : value; +const equalRevision = (a, b) => JSON.stringify(ordered(a)) === JSON.stringify(ordered(b)); +async function optionsFor(options) { + const context = options.context || await getRepositoryContext(); context.assertCurrent(); + const gateway = options.storage ? { storage: options.storage, providerId: options.provider || options.storage.id } : getSyncContext(options); + const storage = { ...gateway.storage }; + for (const method of ['listFiles', 'findFileByName', 'downloadFile', 'createFile', 'updateFile', 'deleteFile']) { + storage[method] = argument => new Promise((resolve, reject) => { + const abort = () => { try { context.assertCurrent(); reject(error('STALE_PROFILE', 'Repository operation was cancelled.')); } catch (failure) { reject(failure); } }; + if (context.signal.aborted) { abort(); return; } + context.signal.addEventListener('abort', abort, { once: true }); + Promise.resolve().then(() => { context.assertCurrent(); return gateway.storage[method](argument, { context }); }).then( + result => { context.signal.removeEventListener('abort', abort); resolve(result); }, + failure => { context.signal.removeEventListener('abort', abort); reject(failure); } + ); + }); + } + return { ...options, ...gateway, storage, context }; +} +function parse(text) { + if (typeof text !== 'string' || new TextEncoder().encode(text).length > MAX_SYNC_PAYLOAD_BYTES) throw error('CORRUPT_REMOTE', 'Remote object is invalid or oversized.'); + try { return JSON.parse(text); } catch { throw error('CORRUPT_REMOTE', 'Remote object contains malformed JSON.'); } +} +async function validateRevision(revision) { + if (!revision || typeof revision.id !== 'string' || !revision.id || typeof revision.projectId !== 'string' || !revision.projectId || !Array.isArray(revision.parentRevisionIds) || revision.parentRevisionIds.some(id => typeof id !== 'string' || !id || id === revision.id) || !['create','update','delete','restore','resolve'].includes(revision.operation) || typeof revision.deviceId !== 'string' || !revision.deviceId || !Number.isFinite(Date.parse(revision.createdAt))) throw error('CORRUPT_REMOTE', 'Remote revision metadata is invalid.'); + const snapshot = await canonicalizeProject(revision.snapshot, { allowIncompleteMedia: true }); + if (snapshot.project.id !== revision.projectId || await hashProject(snapshot) !== revision.payloadHash) throw error('CORRUPT_REMOTE', 'Remote revision payload hash does not match.'); + // History may contain drafts subsequently repaired by a descendant. Applying a + // head still uses the repository's strict validation before any authoring write. + return { ...revision, snapshot }; +} +async function decodeRevision(text, options) { + const envelope = parse(text); + if (envelope.schemaVersion !== 2) throw error('UNSUPPORTED_REMOTE', 'Unsupported immutable revision protocol.'); + const revision = options.providerId === 'steam' ? envelope.revision : await decryptPayload(options.passphrase, envelope.encrypted); + return validateRevision(revision); +} +async function encodeRevision(revision, options) { + const envelope = options.providerId === 'steam' ? { schemaVersion: 2, revision } : { schemaVersion: 2, encrypted: await encryptPayload(options.passphrase, revision) }; + const content = JSON.stringify(envelope); + if (new TextEncoder().encode(content).length > MAX_SYNC_PAYLOAD_BYTES) throw error('PAYLOAD_QUOTA', 'Revision exceeds the sync payload limit; it remains pending.'); + return content; +} +function addRevision(map, revision) { + const previous = map.get(revision.id); + if (previous && !equalRevision(previous, revision)) throw error('REVISION_COLLISION', 'An immutable revision identity has different content.'); + map.set(revision.id, revision); +} +export function ancestry(ancestorId, descendantId, revisions) { + const visited = new Set(), pending = [descendantId]; let incomplete = false; + while (pending.length) { + const id = pending.pop(); if (id === ancestorId) return true; + if (visited.has(id)) continue; visited.add(id); + const revision = revisions.get(id); if (!revision) { incomplete = true; continue; } + pending.push(...revision.parentRevisionIds); + } + return incomplete ? null : false; +} +export function completeAncestry(id, revisions) { + const visiting = new Set(), complete = new Set(), stack = [{ id, exit: false }]; + let missing = false; + while (stack.length) { + const frame = stack.pop(); + if (frame.exit) { visiting.delete(frame.id); complete.add(frame.id); continue; } + if (complete.has(frame.id)) continue; + if (visiting.has(frame.id)) throw error('CORRUPT_REMOTE', 'Remote revision history contains a cycle.'); + const revision = revisions.get(frame.id); if (!revision) { missing = true; continue; } + visiting.add(frame.id); stack.push({ id: frame.id, exit: true }); + for (const parent of revision.parentRevisionIds) { + if (revisions.has(parent) && revisions.get(parent).projectId !== revision.projectId) throw error('CORRUPT_REMOTE', 'Revision parent belongs to another project.'); + stack.push({ id: parent, exit: false }); + } + } + return !missing; +} +function headsFor(revisions, projectId, ids) { + const candidates = [...new Set(ids)].filter(id => revisions.get(id)?.projectId === projectId); + return candidates.filter(id => !candidates.some(other => id !== other && ancestry(id, other, revisions) === true)); +} +async function legacyRevision(text, options) { + const envelope = parse(text); + const legacyPassphrase = options.providerId === 'steam' ? `auto:steam:${options.context.profileId}:v1` : options.passphrase; + const raw = envelope.project ? envelope : await decryptPayload(legacyPassphrase, envelope); + const snapshot = await canonicalizeProject(raw); + const payloadHash = await hashProject(snapshot); + return validateRevision({ id: `legacy-${payloadHash}`, projectId: snapshot.project.id, parentRevisionIds: [], deviceId: 'legacy-import', payloadHash, operation: 'update', snapshot, createdAt: snapshot.project.modifiedAt || snapshot.project.createdAt || '1970-01-01T00:00:00.000Z' }); +} +function validateTombstone(item) { + if (!item || !['project','dialogue'].includes(item.entityType) || typeof item.entityId !== 'string' || !item.entityId || !Number.isFinite(Date.parse(item.deletedAt))) throw error('CORRUPT_REMOTE', 'Deletion evidence is malformed.'); + return { entityType: item.entityType, entityId: item.entityId, projectId: item.projectId || (item.entityType === 'project' ? item.entityId : ''), deletedAt: item.deletedAt }; +} +/** Read-only discovery: catalogue absence never implies deletion or a complete listing. */ +export async function inspectRevisions(input = {}) { + const options = await optionsFor(input), { context, storage } = options; + const revisions = new Map(), remoteIds = new Set(), fileIds = new Map(), tombstones = []; + for (const revision of await context.db.projectRevisions.toArray()) addRevision(revisions, revision); + context.assertCurrent(); + const files = await storage.listFiles({}); context.assertCurrent(); + const unique = new Map(files.map(file => [file.id, file])); + const catalog = await storage.findFileByName(SYNC_CATALOG_FILE_NAME); context.assertCurrent(); + if (catalog?.id) unique.set(catalog.id, { ...catalog, name: SYNC_CATALOG_FILE_NAME }); + for (const file of unique.values()) { + const name = file.name || ''; + if (!name.startsWith(REVISION_PREFIX) && !name.startsWith(DELETION_PREFIX) && !/^mountea-project-.*\.(mnteasnap|mteasnap)$/.test(name) && name !== SYNC_CATALOG_FILE_NAME) continue; + const text = await storage.downloadFile(file.id); context.assertCurrent(); + if (name === SYNC_CATALOG_FILE_NAME) { + const catalogData = parse(text); + if (catalogData.schemaVersion !== 1) throw error('UNSUPPORTED_REMOTE', 'Unsupported legacy catalogue version.'); + if (!catalogData.objects || !Array.isArray(catalogData.objects.projects) || !Array.isArray(catalogData.objects.dialogues) || !Array.isArray(catalogData.tombstones)) throw error('CORRUPT_REMOTE', 'Legacy catalogue is corrupt.'); + tombstones.push(...catalogData.tombstones.map(validateTombstone)); + // The catalogue is only a positive hint. Missing hinted objects fail closed. + for (const entry of catalogData.objects.projects) { + if (!entry.snapshotFileId || unique.has(entry.snapshotFileId)) continue; + const revision = await legacyRevision(await storage.downloadFile(entry.snapshotFileId), options); context.assertCurrent(); + addRevision(revisions, revision); remoteIds.add(revision.id); fileIds.set(revision.id, entry.snapshotFileId); + } + } else if (name.startsWith(DELETION_PREFIX)) { + const envelope = parse(text); if (envelope.schemaVersion !== 2) throw error('UNSUPPORTED_REMOTE', 'Unsupported deletion protocol.'); + tombstones.push(validateTombstone(envelope.tombstone)); + } else { + const revision = name.startsWith(REVISION_PREFIX) ? await decodeRevision(text, options) : await legacyRevision(text, options); + if (name.startsWith(REVISION_PREFIX) && name !== await revisionFileName(revision.projectId, revision.id)) throw error('CORRUPT_REMOTE', 'Remote filename does not match its immutable revision.'); + addRevision(revisions, revision); remoteIds.add(revision.id); fileIds.set(revision.id, file.id); + } + } + // Fetch positively referenced ancestors by deterministic name even when listing is delayed. + const queue = [...remoteIds], visited = new Set(); + while (queue.length) { + const id = queue.pop(); if (visited.has(id)) continue; visited.add(id); + const revision = revisions.get(id); + for (const parentId of revision.parentRevisionIds) { + if (!revisions.has(parentId)) { + const file = await storage.findFileByName(await revisionFileName(revision.projectId, parentId)); context.assertCurrent(); + if (!file?.id) continue; + const parent = await decodeRevision(await storage.downloadFile(file.id), options); context.assertCurrent(); + if (parent.id !== parentId || parent.projectId !== revision.projectId) throw error('CORRUPT_REMOTE', 'Ancestor identity mismatch.'); + addRevision(revisions, parent); fileIds.set(parent.id, file.id); remoteIds.add(parent.id); + } + if (revisions.has(parentId)) queue.push(parentId); + } + } + // Historic tombstones have no expiry. Keep every positive observation indefinitely. + for (const row of await context.db.syncTombstones.toArray()) tombstones.push(validateTombstone(row)); + for (const row of await context.db.syncDeletions.toArray()) tombstones.push(validateTombstone({ ...row, entityType: 'project', entityId: row.projectId })); + context.assertCurrent(); + const deletionMap = new Map(tombstones.map(row => [`${row.entityType}:${row.entityId}:${row.deletedAt}`, row])); + return { options, revisions, remoteIds, fileIds, tombstones: [...deletionMap.values()], catalogueStatus: catalog?.id ? 'readable' : 'missing' }; +} +async function cacheRevision(revision, context) { + await context.db.transaction('rw', context.db.projectRevisions, async () => { context.assertCurrent(); const previous = await context.db.projectRevisions.get(revision.id); if (previous && !equalRevision(previous, revision)) throw error('REVISION_COLLISION', 'Immutable cached revision differs.'); if (!previous) await context.db.projectRevisions.add(revision); }); +} +async function publishRevision(revision, options) { + const { context, providerId, storage } = options; + const state = await context.db.projectState.get(revision.projectId); + if (!state?.deleted) await assertProjectReady(revision.projectId, context); + const key = `delivery:${providerId}:${revision.id}`; + const delivery = await context.db.syncOutbox.get(key); context.assertCurrent(); + if (delivery?.status === 'acknowledged') return; + await context.db.syncOutbox.put({ id: key, provider: providerId, projectId: revision.projectId, localRevisionId: revision.id, payloadHash: revision.payloadHash, status: 'pending', phase: 'queued', createdAt: delivery?.createdAt || now() }); + const name = await revisionFileName(revision.projectId, revision.id); + try { + let file = await storage.findFileByName(name); context.assertCurrent(); + if (!file?.id) { + const content = await encodeRevision(revision, options); context.assertCurrent(); + file = await storage.createFile({ name, content, mimeType: 'application/json', appProperties: { projectId: revision.projectId, revisionId: revision.id, payloadHash: revision.payloadHash, protocol: '2' } }); context.assertCurrent(); + } + await context.db.syncOutbox.update(key, { phase: 'uploaded', remoteFileId: file.id }); context.assertCurrent(); + const verified = await decodeRevision(await storage.downloadFile(file.id), options); context.assertCurrent(); + if (!equalRevision(verified, revision)) throw error('REMOTE_VERIFY_FAILED', 'Uploaded revision failed exact content verification.'); + await context.db.transaction('rw', context.db.syncOutbox, context.db.syncProjects, async () => { + context.assertCurrent(); + await context.db.syncOutbox.update(key, { status: 'acknowledged', phase: 'verified', remoteFileId: file.id, acknowledgedAt: now(), error: '' }); + await context.db.syncProjects.put({ projectId: revision.projectId, provider: providerId, acknowledgedRevisionId: revision.id, remoteFileId: file.id, lastSyncedAt: now() }); + }); + } catch (failure) { + if (!context.signal.aborted) await context.db.syncOutbox.update(key, { status: 'pending', error: failure.code || 'UPLOAD_FAILED' }); + throw failure; + } +} +async function publishDeletionEvidence(tombstone, options) { + const { context, storage } = options; + const content = JSON.stringify({ schemaVersion: 2, tombstone }); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content)); + const id = [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2,'0')).join(''); + const name = `${DELETION_PREFIX}${id}.json`; + let file = await storage.findFileByName(name); context.assertCurrent(); + if (!file?.id) { file = await storage.createFile({ name, content, mimeType: 'application/json' }); context.assertCurrent(); } + if (await storage.downloadFile(file.id) !== content) throw error('REMOTE_VERIFY_FAILED', 'Deletion evidence failed remote verification.'); + context.assertCurrent(); +} +async function tombstoneRevision(tombstone, base, revisions) { + const projectId = tombstone.projectId; + if (!projectId) return null; + const snapshot = base ? structuredClone(base.snapshot) : await canonicalizeProject({ project: { id: projectId, name: 'Deleted project' }, dialogues: [], nodes: [], edges: [], participants: [], categories: [], decorators: [], conditions: [], localizedStrings: [] }); + const parents = [...revisions.values()].filter(row => row.projectId === projectId && row.deviceId === 'legacy-import' && Date.parse(row.createdAt) <= Date.parse(tombstone.deletedAt)).map(row => row.id).sort(); + if (tombstone.entityType === 'dialogue') { + snapshot.dialogues = snapshot.dialogues.filter(row => row.id !== tombstone.entityId); + snapshot.nodes = snapshot.nodes.filter(row => row.dialogueId !== tombstone.entityId); + snapshot.edges = snapshot.edges.filter(row => row.dialogueId !== tombstone.entityId); + snapshot.localizedStrings = snapshot.localizedStrings.filter(row => row.dialogueId !== tombstone.entityId); + } + const payloadHash = await hashProject(snapshot); + // Bind identity to the exact selected baseline, retaining all deletion observations. + const idHash = await hashProject({ tombstone, payloadHash, parents }); + return { id: `deletion-${idHash}`, projectId, parentRevisionIds: parents, deviceId: 'legacy-deletion', operation: tombstone.entityType === 'project' ? 'delete' : 'update', payloadHash, snapshot, createdAt: tombstone.deletedAt }; +} +async function recordConflict(projectId, localId, remoteIds, context, providerId, incomplete = false) { + const ids = [...new Set([localId, ...remoteIds].filter(Boolean))].sort(); + const id = `${providerId}:${projectId}:${ids.join(':')}`; + await context.db.syncConflicts.put({ id, projectId, provider: providerId, localRevisionId: localId, revisionIds: ids, status: 'unresolved', incomplete, createdAt: now() }); + return id; +} +async function runUnlocked(input) { + const inspected = await inspectRevisions(input); + const { options, revisions, remoteIds, tombstones } = inspected, { context, providerId, mode = 'full' } = options; + if (!['list','pull','push','full'].includes(mode)) throw error('INVALID_SYNC_MODE', 'Unsupported sync mode.'); + const states = await context.db.projectState.toArray(), projects = await context.db.projects.toArray(); context.assertCurrent(); + const comparisons = [], failures = [], conflicts = []; + if (mode === 'list') { + const ids = new Set([...projects.map(row => row.id), ...[...remoteIds].map(id => revisions.get(id).projectId)]); + for (const projectId of ids) comparisons.push({ projectId, localRevisionId: states.find(row => row.projectId === projectId)?.revisionId || null, remoteRevisionIds: headsFor(revisions, projectId, [...remoteIds]) }); + return { mode, comparisons, conflicts, failures, catalogueStatus: inspected.catalogueStatus }; + } + // Existing authoring without a revision receives one durable baseline before reconciliation. + for (const project of projects) if (!states.some(state => state.projectId === project.id)) { + const committed = await mutateProject(project.id, { context }); + const revision = await context.db.projectRevisions.get(committed.revisionId); addRevision(revisions, revision); + } + for (const id of remoteIds) await cacheRevision(revisions.get(id), context); + for (const tombstone of tombstones) { + const base = [...revisions.values()].filter(row => row.projectId === tombstone.projectId && row.deviceId === 'legacy-import').sort((a,b) => a.id.localeCompare(b.id))[0]; + const revision = await tombstoneRevision(tombstone, base, revisions); + if (revision) { addRevision(revisions, revision); remoteIds.add(revision.id); await cacheRevision(revision, context); } + await context.db.syncTombstones.put({ ...tombstone, provider: providerId, pending: true }); + } + const freshStates = await context.db.projectState.toArray(); context.assertCurrent(); + const ids = new Set([...freshStates.map(row => row.projectId), ...[...remoteIds].map(id => revisions.get(id).projectId)]); + for (const projectId of ids) { + if (options.projectId && options.projectId !== projectId) continue; + const current = await readProjectState(projectId, context), localId = current.revisionId; + const candidates = [...remoteIds].filter(id => revisions.get(id).projectId === projectId); + if (localId) candidates.push(localId); + const heads = headsFor(revisions, projectId, candidates); + const incomplete = heads.some(id => !completeAncestry(id, revisions)); + comparisons.push({ projectId, localRevisionId: localId, remoteRevisionIds: heads }); + if (!incomplete && heads.length === 1) { + for (const conflict of await context.db.syncConflicts.where('projectId').equals(projectId).toArray()) { + if (conflict.status === 'unresolved' && conflict.revisionIds.every(id => ancestry(id, heads[0], revisions) === true)) await context.db.syncConflicts.update(conflict.id, { status: 'resolved', resolvedRevisionId: heads[0], resolvedAt: now() }); + } + } + if (incomplete || heads.length > 1) { + conflicts.push(await recordConflict(projectId, localId, heads, context, providerId, incomplete)); + } else if (heads.length === 1 && heads[0] !== localId && mode !== 'push') { + const revision = revisions.get(heads[0]); + try { await applyRemoteRevision(revision, { context, expectedSequence: current.sequence }); } + catch (failure) { failures.push({ projectId, code: failure.code || 'APPLY_FAILED', message: failure.message }); } + } + } + if (mode === 'full' || mode === 'push') { + // Publish immutable ancestry, including conflicting local branches. Acknowledgements + // target only each captured revision; a concurrent edit remains independently pending. + const pending = await context.db.syncOutbox.filter(row => !row.provider).toArray(); context.assertCurrent(); + const published = new Set(); + const publishTree = async (id) => { + // Include edits committed while discovery was in flight without acknowledging + // or dropping a different revision. Work arriving later waits for the retry. + if (!revisions.has(id)) { const row = await context.db.projectRevisions.get(id); if (row) addRevision(revisions, row); } + if (!completeAncestry(id, revisions)) throw error('MISSING_ANCESTOR', 'Incomplete revision ancestry remains pending.'); + const stack = [{ id, exit: false }]; + while (stack.length) { + const frame = stack.pop(); if (published.has(frame.id)) continue; + const revision = revisions.get(frame.id); + if (frame.exit) { await publishRevision(revision, options); published.add(frame.id); continue; } + stack.push({ id: frame.id, exit: true }); + for (const parent of revision.parentRevisionIds) stack.push({ id: parent, exit: false }); + } + }; + for (const item of pending) { + if (options.projectId && options.projectId !== item.projectId) continue; + try { await publishTree(item.localRevisionId); } catch (failure) { failures.push({ projectId: item.projectId, code: failure.code || 'UPLOAD_FAILED', message: failure.message }); } + } + for (const tombstone of tombstones) { + try { await publishDeletionEvidence(tombstone, options); await context.db.syncTombstones.update([providerId,tombstone.entityType,tombstone.entityId], { pending: false, acknowledgedAt: now() }); } + catch (failure) { failures.push({ projectId: tombstone.projectId, code: failure.code || 'UPLOAD_FAILED', message: failure.message }); } + } + } + context.assertCurrent(); + return { mode, comparisons, conflicts, failures, catalogueStatus: inspected.catalogueStatus }; +} +export async function syncRevisions(input = {}) { + const options = await optionsFor(input); + let providers = locks.get(options.context.db); if (!providers) { providers = new Map(); locks.set(options.context.db, providers); } + const previous = providers.get(options.providerId) || Promise.resolve(); + const work = previous.catch(() => {}).then(async () => { + options.context.assertCurrent(); const result = await runUnlocked(options); + return { ...result, queue: await getRevisionQueueStatus(options.providerId, options.context) }; + }); + providers.set(options.providerId, work); + try { return await work; } finally { if (providers.get(options.providerId) === work) providers.delete(options.providerId); } +} + +/** Metadata-only provider view: durable base intents remain for future providers. */ +export async function getRevisionQueueStatus(providerId, suppliedContext) { + const context = suppliedContext || await getRepositoryContext(); context.assertCurrent(); + const rows = await context.db.syncOutbox.toArray(); + const deliveries = new Map(rows.filter(row => row.provider === providerId).map(row => [row.localRevisionId, row])); + const intents = rows.filter(row => !row.provider); + const status = { queued: 0, uploaded: 0, verified: 0, pending: 0, oldestPendingAgeMs: 0, conflicts: 0, errorCodes: [] }; + for (const intent of intents) { + const delivery = deliveries.get(intent.localRevisionId); + if (delivery?.status === 'acknowledged') { status.verified++; continue; } + status.pending++; + if (delivery?.phase === 'uploaded') status.uploaded++; else status.queued++; + const createdAt = Date.parse(intent.createdAt); if (Number.isFinite(createdAt)) status.oldestPendingAgeMs = Math.max(status.oldestPendingAgeMs, Date.now() - createdAt); + if (delivery?.error) status.errorCodes.push(delivery.error); + } + status.conflicts = await context.db.syncConflicts.filter(row => row.status === 'unresolved' && row.provider === providerId).count(); + status.errorCodes = [...new Set(status.errorCodes)].sort(); context.assertCurrent(); return status; +} +export async function resolveRevisionConflict(conflictId, choice, input = {}) { + const context = input.context || await getRepositoryContext(); context.assertCurrent(); + const conflict = await context.db.syncConflicts.get(conflictId); + if (!conflict || conflict.status !== 'unresolved' || conflict.incomplete) throw error('CONFLICT_UNAVAILABLE', 'Reload complete revision history before resolving this conflict.'); + if (!['local','remote','both'].includes(choice)) throw error('INVALID_RESOLUTION', 'Choose which revision to retain.'); + const current = await readProjectState(conflict.projectId, context); + if (current.revisionId !== conflict.localRevisionId) throw error('STALE_CONFLICT', 'The project changed; synchronize again before resolving.'); + const revisions = await context.db.projectRevisions.bulkGet(conflict.revisionIds); + if (revisions.some(row => !row)) throw error('MISSING_ANCESTOR', 'Conflict history is missing.'); + const local = revisions.find(row => row.id === conflict.localRevisionId); + const remote = revisions.find(row => row.id !== conflict.localRevisionId); + if (!remote || (choice === 'remote' && conflict.revisionIds.length > 2 && !input.revisionId)) throw error('CHOOSE_REVISION', 'Select a specific competing revision.'); + const selected = input.revisionId ? revisions.find(row => row.id === input.revisionId) : choice === 'remote' ? remote : local || remote; + if (!selected) throw error('CHOOSE_REVISION', 'Select an available revision.'); + let copiedProjectId; + if (choice === 'both') { + for (const competing of revisions.filter(row => row.id !== selected.id && row.operation !== 'delete')) { + const digest = await hashProject({ conflictId, revisionId: competing.id }); + const copyId = `${digest.slice(0,8)}-${digest.slice(8,12)}-4${digest.slice(13,16)}-8${digest.slice(17,20)}-${digest.slice(20,32)}`; + copiedProjectId = copyId; + if (!await context.db.projectState.get(copyId)) await applyProjectSnapshotAsNew(competing.snapshot, { context, projectId: copyId, name: `${competing.snapshot.project.name} (conflict copy)` }); + } + } + const prepared = await prepareProjectCommit(selected.snapshot, { context, expectedSequence: current.sequence, parentRevisionIds: conflict.revisionIds, operation: selected.operation === 'delete' ? 'delete' : 'resolve' }); + const result = await commitPreparedProject(prepared); + await context.db.syncConflicts.update(conflictId, { status: 'resolved', resolvedRevisionId: result.revisionId, resolvedAt: now(), copiedProjectId }); + return { ...result, copiedProjectId }; +} diff --git a/src/lib/sync/core/syncActions.js b/src/lib/sync/core/syncActions.js index 90d28db..5843109 100644 --- a/src/lib/sync/core/syncActions.js +++ b/src/lib/sync/core/syncActions.js @@ -1,775 +1,37 @@ -import { db } from '@/lib/db'; -import { getActiveProfileId } from '@/lib/profile/activeProfile'; -import { encryptPayload, decryptPayload } from '@/lib/sync/crypto'; -import { - MIME_TYPE, - MAX_SYNC_PAYLOAD_BYTES, - SYNC_TOMBSTONE_TTL_MS, -} from '@/lib/sync/core/constants'; -import { - createDefaultProviderCatalog, - mergeProviderCatalogs, - normalizeProviderCatalog, - readProviderCatalog, - writeProviderCatalog, -} from '@/lib/sync/core/providerCatalog'; -import { getSyncContext } from '@/lib/sync/core/providerGateway'; -import { - buildFileName, - findRemoteProjectById, -} from '@/lib/sync/core/remoteCatalog'; -import { - applyProjectSnapshot, - applyProjectSnapshotAsNew, - buildProjectSnapshot, -} from '@/lib/sync/snapshot'; -import { - acknowledgeSyncTombstone, - clearSyncProject, - getSyncProject, - hasActiveTombstone, - listSyncTombstones, - upsertSyncCatalogState, - upsertSyncProject, - upsertSyncTombstone, -} from '@/lib/sync/syncStorage'; -import { formatBytesToMib, getUtf8ByteLength } from '@/lib/sync/payloadBudget'; - -function parseEncryptedPayloadJson(encryptedText, contextLabel = 'sync payload') { - if (typeof encryptedText !== 'string') { - throw new Error(`Invalid ${contextLabel}: expected text payload`); - } - - const payloadBytes = new TextEncoder().encode(encryptedText).length; - if (payloadBytes > MAX_SYNC_PAYLOAD_BYTES) { - throw new Error(`Refusing oversized ${contextLabel}`); - } - - let payload; - try { - payload = JSON.parse(encryptedText); - } catch (error) { - throw new Error(`Invalid ${contextLabel}: malformed JSON`); - } - - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new Error(`Invalid ${contextLabel}: expected object`); - } - - return payload; -} - -function summarizeSnapshot(snapshot) { - const project = snapshot?.project || {}; - const dialogues = snapshot?.dialogues || []; - return { - projectId: project?.id || '', - name: project?.name || '', - version: project?.version || '', - createdAt: project?.createdAt || '', - modifiedAt: project?.modifiedAt || '', - dialogueCount: dialogues.length, - participantCount: snapshot?.participants?.length || 0, - categoryCount: snapshot?.categories?.length || 0, - decoratorCount: snapshot?.decorators?.length || 0, - localizedStringCount: snapshot?.localizedStrings?.length || 0, - nodeCount: snapshot?.nodes?.length || 0, - edgeCount: snapshot?.edges?.length || 0, - dialogueNames: dialogues.slice(0, 5).map((dialogue) => dialogue?.name || '').filter(Boolean), - }; -} - -function toRevision(value) { - const revision = Number(value || 0); - if (Number.isNaN(revision) || revision < 0) return 0; - return Math.floor(revision); -} - -function toTimeMs(value) { - const parsed = Date.parse(value || ''); - return Number.isNaN(parsed) ? 0 : parsed; -} - -function resolvePassphraseForProvider(providerId, passphrase = '', passphrases = null) { - if (passphrases && typeof passphrases === 'object') { - const providerPassphrase = String(passphrases?.[providerId] || '').trim(); - if (providerPassphrase) return providerPassphrase; - } - return String(passphrase || '').trim(); -} - -function normalizeProviders(provider, providers = []) { - const all = []; - for (const entry of Array.isArray(providers) ? providers : []) { - const value = String(entry || '').trim(); - if (value) all.push(value); - } - const providerId = String(provider || '').trim(); - if (providerId) all.push(providerId); - return Array.from(new Set(all)); -} - -async function markProjectSyncTimestamp(projectId, syncTimestamp = '') { - const normalizedProjectId = String(projectId || '').trim(); - if (!normalizedProjectId) return; - const timestamp = syncTimestamp || new Date().toISOString(); - await db.projects.update(normalizedProjectId, { syncTimestamp: timestamp }); - const dialogues = await db.dialogues.where('projectId').equals(normalizedProjectId).toArray(); - if (!dialogues.length) return; - await db.dialogues.bulkPut( - dialogues.map((dialogue) => ({ - ...dialogue, - syncTimestamp: timestamp, - })) - ); -} - -async function deleteLocalDialogueRecords(dialogueId) { - const normalizedDialogueId = String(dialogueId || '').trim(); - if (!normalizedDialogueId) return { deleted: false, reason: 'invalid-dialogue-id' }; - let deleted = false; - await db.transaction('rw', [db.dialogues, db.nodes, db.edges, db.localizedStrings], async () => { - const existing = await db.dialogues.get(normalizedDialogueId); - if (!existing) return; - deleted = true; - await db.dialogues.delete(normalizedDialogueId); - await db.nodes.where('dialogueId').equals(normalizedDialogueId).delete(); - await db.edges.where('dialogueId').equals(normalizedDialogueId).delete(); - await db.localizedStrings.where('dialogueId').equals(normalizedDialogueId).delete(); - }); - return { deleted, dialogueId: normalizedDialogueId }; -} - -async function deleteLocalProjectRecords(projectId, providerId = '') { - const normalizedProjectId = String(projectId || '').trim(); - if (!normalizedProjectId) return { deleted: false, reason: 'invalid-project-id' }; - - let deleted = false; - await db.transaction( - 'rw', - [ - db.projects, - db.dialogues, - db.participants, - db.categories, - db.decorators, - db.conditions, - db.localizedStrings, - db.nodes, - db.edges, - db.syncProjects, - ], - async () => { - const existingProject = await db.projects.get(normalizedProjectId); - deleted = Boolean(existingProject); - const projectDialogues = await db.dialogues.where('projectId').equals(normalizedProjectId).toArray(); - const dialogueIds = projectDialogues.map((dialogue) => dialogue.id); - - if (dialogueIds.length > 0) { - await db.nodes.where('dialogueId').anyOf(dialogueIds).delete(); - await db.edges.where('dialogueId').anyOf(dialogueIds).delete(); - } - - await db.projects.delete(normalizedProjectId); - await db.dialogues.where('projectId').equals(normalizedProjectId).delete(); - await db.participants.where('projectId').equals(normalizedProjectId).delete(); - await db.categories.where('projectId').equals(normalizedProjectId).delete(); - await db.decorators.where('projectId').equals(normalizedProjectId).delete(); - await db.conditions.where('projectId').equals(normalizedProjectId).delete(); - await db.localizedStrings.where('projectId').equals(normalizedProjectId).delete(); - if (providerId) { - await db.syncProjects.delete([normalizedProjectId, providerId]); - } else { - await db.syncProjects.where('projectId').equals(normalizedProjectId).delete(); - } - } - ); - - return { deleted, projectId: normalizedProjectId }; -} - -function buildTombstonePayload({ entityType, entityId, projectId = '', deletedAt = '', expiresAt = '' }) { - const normalizedEntityType = String(entityType || '').trim().toLowerCase(); - if (normalizedEntityType !== 'project' && normalizedEntityType !== 'dialogue') { - throw new Error('Invalid tombstone entity type'); - } - const normalizedEntityId = String(entityId || '').trim(); - if (!normalizedEntityId) { - throw new Error('Invalid tombstone entity id'); - } - const nowIso = new Date().toISOString(); - const normalizedDeletedAt = deletedAt && !Number.isNaN(Date.parse(deletedAt)) ? deletedAt : nowIso; - const normalizedExpiresAt = - expiresAt && !Number.isNaN(Date.parse(expiresAt)) - ? expiresAt - : new Date(toTimeMs(normalizedDeletedAt) + SYNC_TOMBSTONE_TTL_MS).toISOString(); - return { - entityType: normalizedEntityType, - entityId: normalizedEntityId, - projectId: String(projectId || '').trim() || '', - deletedAt: normalizedDeletedAt, - expiresAt: normalizedExpiresAt, - }; -} - -function upsertCatalogTombstone(catalog, tombstone) { - const normalizedCatalog = normalizeProviderCatalog(catalog || createDefaultProviderCatalog()); - const key = `${tombstone.entityType}:${tombstone.entityId}`; - const tombstoneMap = new Map((normalizedCatalog.tombstones || []).map((entry) => [`${entry.entityType}:${entry.entityId}`, entry])); - const existing = tombstoneMap.get(key); - if (!existing || toTimeMs(tombstone.deletedAt) >= toTimeMs(existing.deletedAt)) { - tombstoneMap.set(key, tombstone); - } - return { - ...normalizedCatalog, - tombstones: Array.from(tombstoneMap.values()), - }; -} - -function setProjectInCatalog(catalog, { projectId, snapshotFileId, snapshotRevision, updatedAt, dialogueIds = [] }) { - const normalizedCatalog = normalizeProviderCatalog(catalog || createDefaultProviderCatalog()); - const nextProjects = (normalizedCatalog.objects.projects || []).filter( - (entry) => String(entry?.projectId || '').trim() !== projectId - ); - nextProjects.push({ projectId, snapshotFileId, snapshotRevision: toRevision(snapshotRevision), updatedAt }); - const nextDialogues = (normalizedCatalog.objects.dialogues || []).filter( - (entry) => String(entry?.projectId || '').trim() !== projectId - ); - for (const dialogueId of dialogueIds) { - nextDialogues.push({ dialogueId, projectId, updatedAt }); - } - return { - ...normalizedCatalog, - objects: { - projects: nextProjects, - dialogues: nextDialogues, - }, - }; -} - -async function refreshCatalogState(providerId, catalogRevision = 0, status = 'ok', error = '') { - await upsertSyncCatalogState(providerId, getActiveProfileId(), { - catalogRevision, - fetchedAt: new Date().toISOString(), - status, - error, - }); -} - -export async function previewPullFromFile({ projectId, fileId, revision, passphrase, provider }) { - const { storage } = getSyncContext({ provider }); - const encryptedText = await storage.downloadFile(fileId); - const payload = parseEncryptedPayloadJson(encryptedText, 'remote sync payload'); - const snapshot = await decryptPayload(passphrase, payload); - return { - projectId, - fileId, - revision: revision ?? 0, - summary: summarizeSnapshot(snapshot), - }; -} - -export async function previewPushProject({ projectId }) { - const snapshot = await buildProjectSnapshot(projectId); - return { - projectId, - summary: summarizeSnapshot(snapshot), - }; -} - -export async function pullProjectFromFile({ projectId, fileId, revision, passphrase, provider }) { - const { providerId, storage } = getSyncContext({ provider }); - const encryptedText = await storage.downloadFile(fileId); - const payload = parseEncryptedPayloadJson(encryptedText, 'remote sync payload'); - const snapshot = await decryptPayload(passphrase, payload); - await applyProjectSnapshot(snapshot); - const nowIso = new Date().toISOString(); - await markProjectSyncTimestamp(projectId, nowIso); - await upsertSyncProject(projectId, providerId, { - revision: revision ?? 0, - remoteFileId: fileId, - lastSyncedAt: nowIso, - }); - return { pulled: true, revision: revision ?? 0 }; -} - -export async function pullProject({ projectId, passphrase, onProgress, provider }) { - const { providerId, storage } = getSyncContext({ provider }); - onProgress?.('checking', 20); - const remoteFile = await findRemoteProjectById(projectId, { provider: providerId }); - if (!remoteFile) { - return { pulled: false, reason: 'missing' }; - } - onProgress?.('downloading', 45); - const encryptedText = await storage.downloadFile(remoteFile.fileId); - const payload = parseEncryptedPayloadJson(encryptedText, 'remote sync payload'); - onProgress?.('decrypting', 70); - const snapshot = await decryptPayload(passphrase, payload); - onProgress?.('applying', 90); - await applyProjectSnapshot(snapshot); - const nowIso = new Date().toISOString(); - await markProjectSyncTimestamp(projectId, nowIso); - const remoteRevision = Number(remoteFile.revision || 0); - await upsertSyncProject(projectId, providerId, { - revision: remoteRevision, - remoteFileId: remoteFile.fileId, - lastSyncedAt: nowIso, - }); - return { pulled: true, revision: remoteRevision }; -} - -export async function pullProjectAsNew({ projectId, passphrase, fileId, revision, onProgress, provider }) { - const { storage } = getSyncContext({ provider }); - const fileName = buildFileName(projectId); - onProgress?.('checking', 20); - const remoteFile = fileId ? { id: fileId } : await storage.findFileByName(fileName); - if (!remoteFile) return { pulled: false, reason: 'missing' }; - onProgress?.('downloading', 45); - const encryptedText = await storage.downloadFile(remoteFile.id); - const payload = parseEncryptedPayloadJson(encryptedText, 'remote sync payload'); - onProgress?.('decrypting', 70); - const snapshot = await decryptPayload(passphrase, payload); - onProgress?.('applying', 90); - const newProjectId = await applyProjectSnapshotAsNew(snapshot); - return { pulled: true, sourceProjectId: projectId, newProjectId, revision: revision ?? 0 }; -} -export async function pushProject({ projectId, passphrase, provider, mergedTombstones = [] }) { - const { providerId, storage } = getSyncContext({ provider }); - const normalizedProjectId = String(projectId || '').trim(); - if (!normalizedProjectId) { - throw new Error('Invalid project id'); - } - - const tombstonedInMerge = mergedTombstones.some( - (entry) => - entry?.entityType === 'project' && - String(entry?.entityId || '').trim() === normalizedProjectId && - !(entry?.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) - ); - if (tombstonedInMerge) { - throw new Error(`Push blocked: project ${normalizedProjectId} is tombstoned`); - } - - const hasLocalProjectTombstone = await hasActiveTombstone({ - entityType: 'project', - entityId: normalizedProjectId, - }); - if (hasLocalProjectTombstone) { - throw new Error(`Push blocked: project ${normalizedProjectId} is tombstoned`); - } - - const snapshot = await buildProjectSnapshot(normalizedProjectId); - const snapshotDialogueIds = Array.isArray(snapshot?.dialogues) - ? snapshot.dialogues.map((dialogue) => String(dialogue?.id || '').trim()).filter(Boolean) - : []; - for (const dialogueId of snapshotDialogueIds) { - const hasDialogueTombstone = await hasActiveTombstone({ - entityType: 'dialogue', - entityId: dialogueId, - }); - if (hasDialogueTombstone) { - throw new Error(`Push blocked: dialogue ${dialogueId} is tombstoned`); - } - } - - const encryptedPayload = await encryptPayload(passphrase, snapshot); - const content = JSON.stringify(encryptedPayload); - const contentBytes = getUtf8ByteLength(content); - if (contentBytes > MAX_SYNC_PAYLOAD_BYTES) { - throw new Error(`Sync payload is too large (${formatBytesToMib(contentBytes)} MiB > ${(MAX_SYNC_PAYLOAD_BYTES / (1024 * 1024)).toFixed(0)} MiB limit).`); - } - const fileName = buildFileName(normalizedProjectId); - const remoteFile = await findRemoteProjectById(normalizedProjectId, { provider: providerId }); - const localMeta = await getSyncProject(normalizedProjectId, providerId); - const localRevision = toRevision(localMeta?.revision); - const nextRevision = localRevision + 1; - const nowIso = new Date().toISOString(); - - const appProperties = { - revision: String(nextRevision), - projectId: normalizedProjectId, - profileId: String(getActiveProfileId() || 'local'), - schemaVersion: '1', - updatedAt: nowIso, - }; - - let result; - if (remoteFile?.fileId) { - result = await storage.updateFile({ - fileId: remoteFile.fileId, - content, - mimeType: MIME_TYPE, - appProperties, - }); - } else { - result = await storage.createFile({ - name: fileName, - content, - mimeType: MIME_TYPE, - appProperties, - }); - } - - const remoteFileId = String(result?.id || remoteFile?.fileId || '').trim(); - await upsertSyncProject(normalizedProjectId, providerId, { - revision: nextRevision, - remoteFileId, - lastSyncedAt: nowIso, - }); - await markProjectSyncTimestamp(normalizedProjectId, nowIso); - - const readResult = await readProviderCatalog(providerId); - const nextCatalog = setProjectInCatalog(readResult?.catalog, { - projectId: normalizedProjectId, - snapshotFileId: remoteFileId, - snapshotRevision: nextRevision, - updatedAt: nowIso, - dialogueIds: snapshotDialogueIds, - }); - const writeResult = await writeProviderCatalog(providerId, nextCatalog, { - fileId: readResult?.fileId || '', - }); - await refreshCatalogState(providerId, toRevision(writeResult.catalog?.catalogRevision), 'ok', ''); - - return { pushed: true, revision: nextRevision, providerId }; -} - -export async function publishTombstone({ provider, entityType, entityId, projectId = '', deletedAt = '', expiresAt = '' }) { - const { providerId, storage } = getSyncContext({ provider }); - const tombstone = buildTombstonePayload({ entityType, entityId, projectId, deletedAt, expiresAt }); - const readResult = await readProviderCatalog(providerId); - if (!readResult?.readable) { - throw new Error(`Provider ${providerId} catalog is unavailable`); - } - - let nextCatalog = normalizeProviderCatalog(readResult.catalog); - const existingProject = (nextCatalog.objects.projects || []).find( - (entry) => String(entry?.projectId || '').trim() === String(tombstone.entityId || '').trim() - ); - nextCatalog = upsertCatalogTombstone(nextCatalog, tombstone); - if (tombstone.entityType === 'project') { - nextCatalog = { - ...nextCatalog, - objects: { - projects: (nextCatalog.objects.projects || []).filter( - (entry) => String(entry?.projectId || '').trim() !== tombstone.entityId - ), - dialogues: (nextCatalog.objects.dialogues || []).filter( - (entry) => String(entry?.projectId || '').trim() !== tombstone.entityId - ), - }, - }; - } - if (tombstone.entityType === 'dialogue') { - nextCatalog = { - ...nextCatalog, - objects: { - ...nextCatalog.objects, - dialogues: (nextCatalog.objects.dialogues || []).filter( - (entry) => String(entry?.dialogueId || '').trim() !== tombstone.entityId - ), - }, - }; - } - - const writeResult = await writeProviderCatalog(providerId, nextCatalog, { - fileId: readResult.fileId || '', - }); - await refreshCatalogState(providerId, toRevision(writeResult.catalog?.catalogRevision), 'ok', ''); - - if (tombstone.entityType === 'project') { - let fileIdToDelete = String(existingProject?.snapshotFileId || '').trim(); - if (!fileIdToDelete) { - const remote = await findRemoteProjectById(tombstone.entityId, { provider: providerId }); - fileIdToDelete = String(remote?.fileId || '').trim(); - } - if (fileIdToDelete && typeof storage.deleteFile === 'function') { - await storage.deleteFile(fileIdToDelete); - } - await clearSyncProject(tombstone.entityId, providerId); - } - - await upsertSyncTombstone({ - provider: providerId, - entityType: tombstone.entityType, - entityId: tombstone.entityId, - projectId: tombstone.projectId, - deletedAt: tombstone.deletedAt, - expiresAt: tombstone.expiresAt, - pending: false, - acknowledgedAt: new Date().toISOString(), - }); - await acknowledgeSyncTombstone({ - provider: providerId, - entityType: tombstone.entityType, - entityId: tombstone.entityId, - }); - - return { published: true, providerId, tombstone }; -} - -export async function applyMergedTombstones({ tombstones = [], fallbackProviders = [] } = {}) { - const nowMs = Date.now(); - let deletedProjects = 0; - let deletedDialogues = 0; - for (const entry of tombstones) { - const entityType = String(entry?.entityType || '').trim().toLowerCase(); - const entityId = String(entry?.entityId || '').trim(); - if (!entityId) continue; - const expiresAtMs = entry?.expiresAt ? Date.parse(entry.expiresAt) : 0; - if (expiresAtMs && expiresAtMs <= nowMs) continue; - - if (entityType === 'project') { - const result = await deleteLocalProjectRecords(entityId); - if (result?.deleted) deletedProjects += 1; - } else if (entityType === 'dialogue') { - const result = await deleteLocalDialogueRecords(entityId); - if (result?.deleted) deletedDialogues += 1; - } else { - continue; - } - - const providers = Array.from( - new Set([ - ...(Array.isArray(entry?.sourceProviders) ? entry.sourceProviders : []), - ...fallbackProviders, - ].filter(Boolean)) - ); - for (const providerId of providers) { - await upsertSyncTombstone({ - provider: providerId, - entityType, - entityId, - projectId: String(entry?.projectId || '').trim() || '', - deletedAt: entry?.deletedAt, - expiresAt: entry?.expiresAt, - pending: false, - acknowledgedAt: new Date().toISOString(), - }); - } - } - return { deletedProjects, deletedDialogues }; -} - -export async function gcExpiredTombstones({ providers = [] } = {}) { - const providerIds = normalizeProviders('', providers); - const nowMs = Date.now(); - const localRows = await db.syncTombstones.toArray(); - const expiredRows = localRows.filter((row) => row?.expiresAt && Date.parse(row.expiresAt) <= nowMs && !row.pending); - if (expiredRows.length > 0) { - await db.syncTombstones.bulkDelete( - expiredRows.map((row) => [row.provider, row.entityType, row.entityId]) - ); - } - - let catalogPurged = 0; - for (const providerId of providerIds) { - const readResult = await readProviderCatalog(providerId); - if (!readResult?.readable) continue; - const catalog = normalizeProviderCatalog(readResult.catalog); - const nextTombstones = (catalog.tombstones || []).filter((entry) => !(entry?.expiresAt && Date.parse(entry.expiresAt) <= nowMs)); - if (nextTombstones.length === (catalog.tombstones || []).length) continue; - catalogPurged += (catalog.tombstones || []).length - nextTombstones.length; - await writeProviderCatalog(providerId, { - ...catalog, - tombstones: nextTombstones, - }, { - fileId: readResult.fileId || '', - }); - } - - return { localPurged: expiredRows.length, catalogPurged }; -} - -export async function deleteRemoteProject({ projectId, provider }) { - const { providerId, storage } = getSyncContext({ provider }); - if (typeof storage.deleteFile !== 'function') { - throw new Error(`Sync provider "${providerId}" does not support remote deletion`); - } - - const remoteFile = await findRemoteProjectById(projectId, { provider: providerId }); - if (!remoteFile?.fileId) { - await clearSyncProject(projectId, providerId); - return { deleted: false, reason: 'missing' }; - } - - await storage.deleteFile(remoteFile.fileId); - await clearSyncProject(projectId, providerId); - return { deleted: true, fileId: remoteFile.fileId }; -} - -export async function deleteLocalProject({ projectId, provider }) { - const { providerId } = getSyncContext({ provider }); - return await deleteLocalProjectRecords(projectId, providerId); -} - -export async function syncAllProjects({ - passphrase, - passphrases = null, - onProgress, - provider, - providers = [], - mode = 'full', -}) { - const providerIds = normalizeProviders(provider, providers); - if (!providerIds.length) { - throw new Error('No sync provider available'); - } - - const readResults = await Promise.all(providerIds.map(async (providerId) => await readProviderCatalog(providerId))); - for (const readResult of readResults) { - if (readResult?.readable) { - await refreshCatalogState(readResult.providerId, toRevision(readResult.catalog?.catalogRevision), 'ok', ''); - } else { - await refreshCatalogState(readResult.providerId, 0, 'unavailable', String(readResult?.error || 'catalog-read-failed')); - } - } - const readableCatalogs = readResults.filter((entry) => entry?.readable); - if (!readableCatalogs.length) { - return { - mode, - providers: providerIds, - readableProviders: [], - skippedDestructive: true, - remoteCount: 0, - localCount: await db.projects.count(), - }; - } - - const mergedCatalog = mergeProviderCatalogs(readableCatalogs); - const readableProviders = readableCatalogs.map((entry) => entry.providerId); - await applyMergedTombstones({ - tombstones: mergedCatalog.tombstones, - fallbackProviders: readableProviders, - }); - - const remoteProjects = Array.isArray(mergedCatalog.objects?.projects) ? mergedCatalog.objects.projects : []; - const remoteMap = new Map(remoteProjects.map((entry) => [entry.projectId, entry])); - const localProjects = await db.projects.toArray(); - const localMap = new Map(localProjects.map((entry) => [entry.id, entry])); - const allSyncMeta = await db.syncProjects.toArray(); - const metaMap = new Map(allSyncMeta.map((entry) => [`${entry.projectId}::${entry.provider}`, entry])); - const localProjectTombstones = await listSyncTombstones({ - entityType: 'project', - includeExpired: false, - }); - const localProjectTombstoneProviders = new Map(); - for (const entry of localProjectTombstones) { - const projectId = String(entry?.entityId || '').trim(); - const providerId = String(entry?.provider || '').trim(); - if (!projectId || !providerId) continue; - if (!localProjectTombstoneProviders.has(projectId)) { - localProjectTombstoneProviders.set(projectId, new Set()); - } - localProjectTombstoneProviders.get(projectId).add(providerId); - } - const toPull = []; - const toPush = []; - for (const remote of remoteProjects) { - if (mode === 'push') continue; - const projectId = String(remote?.projectId || '').trim(); - const sourceProvider = String(remote?.sourceProvider || '').trim(); - if (!projectId || !sourceProvider) continue; - const localTombstoneProviders = localProjectTombstoneProviders.get(projectId); - if (localTombstoneProviders?.has(sourceProvider)) { - continue; - } - const local = localMap.get(projectId); - const meta = metaMap.get(`${projectId}::${sourceProvider}`) || null; - const localRevision = toRevision(meta?.revision); - const remoteRevision = toRevision(remote?.snapshotRevision); - if (!local || remoteRevision > localRevision || (!meta && toTimeMs(remote.updatedAt) > toTimeMs(local?.syncTimestamp))) { - toPull.push({ - projectId, - providerId: sourceProvider, - fileId: String(remote?.snapshotFileId || '').trim(), - revision: remoteRevision, - }); - } - } - - for (const local of localProjects) { - if (mode === 'pull') continue; - const projectId = String(local?.id || '').trim(); - if (!projectId) continue; - const remote = remoteMap.get(projectId); - if (!remote) { - const hasAnyMeta = allSyncMeta.some((entry) => String(entry?.projectId || '').trim() === projectId); - if (!hasAnyMeta && !local?.syncTimestamp) { - toPush.push({ projectId, providerId: providerIds[0] }); - } - continue; - } - - const sourceProvider = String(remote?.sourceProvider || providerIds[0] || '').trim(); - if (!sourceProvider) continue; - const meta = metaMap.get(`${projectId}::${sourceProvider}`) || null; - const remoteRevision = toRevision(remote?.snapshotRevision); - const localRevision = toRevision(meta?.revision); - const hasUnsyncedChanges = toTimeMs(local?.modifiedAt) > toTimeMs(meta?.lastSyncedAt || local?.syncTimestamp) + 1000; - if (!meta || localRevision > remoteRevision || hasUnsyncedChanges) { - toPush.push({ projectId, providerId: sourceProvider }); - } - } - - const total = toPull.length + toPush.length; - onProgress?.({ - phase: 'start', - mode, - total, - remoteCount: remoteProjects.length, - localCount: localProjects.length, - }); - - let index = 0; - for (const action of toPull) { - index += 1; - onProgress?.({ phase: 'pull', projectId: action.projectId, providerId: action.providerId, index, total }); - if (!action.fileId) continue; - const providerPassphrase = resolvePassphraseForProvider(action.providerId, passphrase, passphrases); - if (!providerPassphrase) { - console.warn('[sync] Missing passphrase, skipping pull for provider', action.providerId); - continue; - } - await pullProjectFromFile({ - projectId: action.projectId, - fileId: action.fileId, - revision: action.revision, - passphrase: providerPassphrase, - provider: action.providerId, - }); - } - - for (const action of toPush) { - index += 1; - onProgress?.({ phase: 'push', projectId: action.projectId, providerId: action.providerId, index, total }); - const providerPassphrase = resolvePassphraseForProvider(action.providerId, passphrase, passphrases); - if (!providerPassphrase) { - console.warn('[sync] Missing passphrase, skipping push for provider', action.providerId); - continue; - } - await pushProject({ - projectId: action.projectId, - passphrase: providerPassphrase, - provider: action.providerId, - mergedTombstones: mergedCatalog.tombstones, - }); - } - - await gcExpiredTombstones({ providers: readableProviders }); - - return { - mode, - providers: providerIds, - readableProviders, - remoteCount: remoteProjects.length, - localCount: localProjects.length, - toPull: toPull.length, - toPush: toPush.length, - pendingTombstones: (await listSyncTombstones({ pendingOnly: true, includeExpired: false })).length, - }; -} - - - - +/** Compatibility entry points route through immutable revision synchronization. + * Legacy mutable snapshots/catalogues are read-only migration inputs. */ +import { getRepositoryContext } from '@/lib/db'; +import { mutateProject, readProjectState } from '@/lib/persistence/projectRepository'; +import { syncRevisions, inspectRevisions, completeAncestry } from './revisionProtocol'; +import { applyProjectSnapshotAsNew } from '@/lib/sync/snapshot'; +import { upsertSyncTombstone } from '@/lib/sync/syncStorage'; + +export const pushProject = options => syncRevisions({ ...options, mode: 'push' }); +export const pullProject = options => syncRevisions({ ...options, mode: 'pull' }); +export const pullProjectFromFile = options => pullProject(options); +export const syncAllProjects = options => syncRevisions(options); +export const previewPushProject = async ({ projectId, context }) => readProjectState(projectId, context); +export const previewPullFromFile = options => inspectRevisions(options); +export async function pullProjectAsNew(options = {}) { + const inspected = await inspectRevisions(options); + const candidates = [...inspected.remoteIds].map(id => inspected.revisions.get(id)).filter(row => row.projectId === options.projectId && (!options.fileId || inspected.fileIds.get(row.id) === options.fileId)); + if (candidates.length !== 1 || !completeAncestry(candidates[0].id, inspected.revisions)) throw new Error('Select one complete revision before copying this project.'); + if (candidates[0].operation === 'delete') throw new Error('A deletion revision has no project to copy.'); + return applyProjectSnapshotAsNew(candidates[0].snapshot, { context: inspected.options.context }); +} +export async function deleteLocalProject({ projectId, context: suppliedContext } = {}) { + const context = suppliedContext || await getRepositoryContext(); + if (!await context.db.projects.get(projectId)) return; + return mutateProject(projectId, { context, operation: 'delete' }); +} +export async function deleteRemoteProject(options = {}) { + await deleteLocalProject(options); + return pushProject(options); +} +export async function publishTombstone(options = {}) { + await upsertSyncTombstone({ ...options, pending: true }); + return pushProject(options); +} +export const applyMergedTombstones = options => syncRevisions({ ...options, mode: 'pull' }); +// Deletion observations remain durable; no age-based garbage collection. +export const gcExpiredTombstones = async () => ({ removed: 0 }); diff --git a/src/lib/sync/credentialStore.js b/src/lib/sync/credentialStore.js new file mode 100644 index 0000000..1537783 --- /dev/null +++ b/src/lib/sync/credentialStore.js @@ -0,0 +1,79 @@ +import { getRepositoryContext } from '@/lib/db'; + +// Secrets never enter Zustand persistence or IndexedDB. The desktop vault is +// the only persistent destination; a browser session uses this process memory. +const sessionSecrets = new Map(); +const bridge = () => globalThis.window?.electronAPI; +const keyFor = (context, key) => `${context.profileId}:${key}`; + +export async function credentialCapabilities() { + const api = bridge(); + return api?.isElectron && api.credentialStatus ? api.credentialStatus() : { canRemember: false }; +} + +export async function readSecret(key, context = null) { + context ||= await getRepositoryContext(); + const cached = sessionSecrets.get(keyFor(context, key)); + if (cached !== undefined) { context.assertCurrent(); return cached; } + const api = bridge(); + const value = api?.isElectron && api.getCredential ? await api.getCredential({ profileId: context.profileId, key }) : null; + context.assertCurrent(); + if (value !== null) sessionSecrets.set(keyFor(context, key), value); + return value; +} + +export async function writeSecret(key, value, { remember = false, context = null } = {}) { + context ||= await getRepositoryContext(); + context.assertCurrent(); + const api = bridge(); + if (remember) { + if (!(await credentialCapabilities()).canRemember) throw new Error('Secure credential storage is unavailable'); + context.assertCurrent(); + await api.setCredential({ profileId: context.profileId, key, value }); + } else if (api?.isElectron && api.removeCredential) { + await api.removeCredential({ profileId: context.profileId, key }); + } + context.assertCurrent(); + sessionSecrets.set(keyFor(context, key), value); +} + +export async function removeSecret(key, context = null) { + context ||= await getRepositoryContext(); + context.assertCurrent(); + const api = bridge(); + if (api?.isElectron && api.removeCredential) await api.removeCredential({ profileId: context.profileId, key }); + context.assertCurrent(); + sessionSecrets.delete(keyFor(context, key)); +} + +export async function readAccount(provider, context = null) { + context ||= await getRepositoryContext(); + const stored = await readSecret(`${provider}-account`, context); + if (stored) return JSON.parse(stored); + // Migrate only the active generation. The historical source database remains + // untouched as recovery evidence. Browser users explicitly authenticate again. + const legacy = await context.db.syncAccounts.get(provider); + context.assertCurrent(); + if (!legacy || !(await credentialCapabilities()).canRemember) return null; + await writeSecret(`${provider}-account`, JSON.stringify(legacy), { remember: true, context }); + context.assertCurrent(); + await context.db.syncAccounts.delete(provider); + return legacy; +} + +export async function writeAccount(provider, data, context = null) { + context ||= await getRepositoryContext(); + const payload = { provider, ...data }; + await writeSecret(`${provider}-account`, JSON.stringify(payload), { remember: (await credentialCapabilities()).canRemember, context }); + context.assertCurrent(); + await context.db.syncAccounts.delete(provider); + return payload; +} + +export async function removeAccount(provider, context = null) { + context ||= await getRepositoryContext(); + await removeSecret(`${provider}-account`, context); + await removeSecret(`${provider}-passphrase`, context); + context.assertCurrent(); + await context.db.syncAccounts.delete(provider); +} diff --git a/src/lib/sync/crypto.js b/src/lib/sync/crypto.js index c60bf88..8ee3467 100644 --- a/src/lib/sync/crypto.js +++ b/src/lib/sync/crypto.js @@ -44,6 +44,7 @@ async function deriveKey(passphrase, salt) { } export async function encryptPayload(passphrase, data) { + if (typeof passphrase !== 'string' || !passphrase.trim()) throw new Error('Encryption passphrase is required'); const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveKey(passphrase, salt); @@ -64,13 +65,14 @@ export async function encryptPayload(passphrase, data) { } export async function decryptPayload(passphrase, payload) { - if (!payload?.salt || !payload?.iv || !payload?.ciphertext) { + if (payload?.version !== 1 || typeof passphrase !== 'string' || !passphrase.trim() || !['salt', 'iv', 'ciphertext'].every((field) => typeof payload[field] === 'string' && /^[A-Za-z0-9_-]+$/.test(payload[field])) || payload.ciphertext.length > 32 * 1024 * 1024) { throw new Error('Invalid encrypted payload'); } const salt = fromBase64Url(payload.salt); const iv = fromBase64Url(payload.iv); const ciphertext = fromBase64Url(payload.ciphertext); + if (salt.byteLength !== 16 || iv.byteLength !== 12 || ciphertext.byteLength < 16) throw new Error('Invalid encrypted payload parameters'); const key = await deriveKey(passphrase, salt); const plainBuffer = await crypto.subtle.decrypt( diff --git a/src/lib/sync/googleDriveAuth.js b/src/lib/sync/googleDriveAuth.js index 44d0c25..38395d8 100644 --- a/src/lib/sync/googleDriveAuth.js +++ b/src/lib/sync/googleDriveAuth.js @@ -87,38 +87,50 @@ function openPopup(url) { ); } -function waitForAuthResult(expectedState) { +function waitForAuthResult(expectedState, popup, signal) { return new Promise((resolve, reject) => { + let settled = false; + let timeout; + let closedPoll; + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + clearInterval(closedPoll); + window.removeEventListener('message', handler); + signal?.removeEventListener('abort', abort); + try { if (!popup.closed) popup.close(); } catch { /* Already detached. */ } + if (error) reject(error); else resolve(result); + }; + const abort = () => finish(new DOMException('Authentication cancelled', 'AbortError')); const handler = (event) => { - if (event.origin !== window.location.origin) return; + if (event.origin !== window.location.origin || event.source !== popup) return; const { type, code, state, error, accessToken, expiresIn, tokenType, scope } = event.data || {}; - if (type !== 'GOOGLE_OAUTH_RESULT') return; - if (state !== expectedState) return; - window.removeEventListener('message', handler); - if (error) { - reject(new Error(error)); - } else { - resolve({ code, accessToken, expiresIn, tokenType, scope }); - } + if (type !== 'GOOGLE_OAUTH_RESULT' || state !== expectedState) return; + finish(error ? new Error(String(error)) : null, { code, accessToken, expiresIn, tokenType, scope }); }; window.addEventListener('message', handler); + signal?.addEventListener('abort', abort, { once: true }); + timeout = setTimeout(() => finish(new Error('Authentication timed out after 60 seconds')), 60000); + closedPoll = setInterval(() => { if (popup.closed) finish(new Error('Authentication window was closed')); }, 250); + if (signal?.aborted) abort(); }); } -export async function startGoogleDriveAuth() { +export async function startGoogleDriveAuth({ signal } = {}) { + if (signal?.aborted) throw new DOMException('Authentication cancelled', 'AbortError'); const clientId = getClientId(); const scopes = getGoogleDriveScopes(); const electronApi = getElectronApi(); if (electronApi?.isElectron && typeof electronApi.startGoogleOAuth === 'function') { - const desktopClientSecret = - import.meta.env.VITE_GOOGLE_CLIENT_SECRET_DESKTOP || - import.meta.env.VITE_GOOGLE_CLIENT_SECRET || - ''; - const result = await electronApi.startGoogleOAuth({ + const cancel = () => { electronApi.cancelGoogleOAuth?.().catch(() => {}); }; + signal?.addEventListener('abort', cancel, { once: true }); + let result; + try { result = await electronApi.startGoogleOAuth({ clientId, - clientSecret: desktopClientSecret, scopes, - }); + }); } finally { signal?.removeEventListener('abort', cancel); } + if (signal?.aborted) throw new DOMException('Authentication cancelled', 'AbortError'); if (!result?.accessToken) { throw new Error('Missing access token'); @@ -151,12 +163,13 @@ export async function startGoogleDriveAuth() { const authUrl = `${AUTH_ENDPOINT}?${params.toString()}`; const popup = openPopup(authUrl); if (!popup) { + clearAuthState(); throw new Error('Popup blocked'); } let result; try { - result = await waitForAuthResult(state); + result = await waitForAuthResult(state, popup, signal); } finally { clearAuthState(); } @@ -206,7 +219,7 @@ export async function exchangeCodeForToken({ code, redirectUri, clientId }) { return await response.json(); } -export async function refreshAccessToken(refreshToken) { +export async function refreshAccessToken(refreshToken, { signal } = {}) { const clientId = getClientId(); const body = new URLSearchParams({ client_id: clientId, @@ -215,6 +228,7 @@ export async function refreshAccessToken(refreshToken) { }); const response = await fetch(TOKEN_ENDPOINT, { + signal, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', diff --git a/src/lib/sync/googleDriveClient.js b/src/lib/sync/googleDriveClient.js index 58a74a2..30dfbb2 100644 --- a/src/lib/sync/googleDriveClient.js +++ b/src/lib/sync/googleDriveClient.js @@ -1,13 +1,16 @@ import { getSyncAccount, upsertSyncAccount } from '@/lib/sync/syncStorage'; import { refreshAccessToken } from '@/lib/sync/googleDriveAuth'; import { getGoogleDriveSyncRoot } from '@/lib/sync/googleDriveConfig'; +import { getRepositoryContext } from '@/lib/db'; const DRIVE_FILES_ENDPOINT = 'https://www.googleapis.com/drive/v3/files'; const DRIVE_UPLOAD_ENDPOINT = 'https://www.googleapis.com/upload/drive/v3/files'; const ALLOWED_PREFIXES = [DRIVE_FILES_ENDPOINT, DRIVE_UPLOAD_ENDPOINT]; -async function getValidAccessToken() { - const account = await getSyncAccount('googleDrive'); +async function getValidAccessToken(context) { + context.assertCurrent(); + const account = await getSyncAccount('googleDrive', context); + context.assertCurrent(); if (!account?.accessToken) { throw new Error('Missing Google Drive token'); } @@ -21,14 +24,16 @@ async function getValidAccessToken() { throw new Error('tokenExpired'); } - const refreshed = await refreshAccessToken(account.refreshToken); + const refreshed = await refreshAccessToken(account.refreshToken, { signal: context.signal }); + context.assertCurrent(); const nextExpiresAt = Date.now() + refreshed.expires_in * 1000; await upsertSyncAccount('googleDrive', { ...account, accessToken: refreshed.access_token, expiresAt: nextExpiresAt, - }); + }, context); + context.assertCurrent(); return refreshed.access_token; } @@ -44,19 +49,23 @@ function buildDriveUrl(base, pathSegment, params) { return url; } -async function driveRequest(url, options = {}) { +async function driveRequest(url, options, context) { + context.assertCurrent(); const urlString = url.toString(); if (!ALLOWED_PREFIXES.some((prefix) => urlString.startsWith(prefix))) { throw new Error('Blocked non-Google Drive request'); } - const token = await getValidAccessToken(); + const token = await getValidAccessToken(context); + context.assertCurrent(); const response = await fetch(urlString, { ...options, + signal: context.signal, headers: { Authorization: `Bearer ${token}`, ...options.headers, }, }); + context.assertCurrent(); if (!response.ok) { throw new Error(`Google Drive request failed: ${response.status}`); @@ -126,7 +135,8 @@ function appendFileAccessParams(params, syncRoot) { } } -export async function findAppDataFile(fileName) { +export async function findAppDataFile(fileName, { context } = {}) { + context ||= await getRepositoryContext(); context.assertCurrent(); const syncRoot = getGoogleDriveSyncRoot(); const query = buildFindFileQuery(fileName, syncRoot); @@ -137,12 +147,14 @@ export async function findAppDataFile(fileName) { appendScopeParams(params, syncRoot); const url = buildDriveUrl(DRIVE_FILES_ENDPOINT, null, params); - const response = await driveRequest(url); + const response = await driveRequest(url, {}, context); const data = await response.json(); + context.assertCurrent(); return data.files?.[0] || null; } -export async function listAppDataFiles({ namePrefix } = {}) { +export async function listAppDataFiles({ namePrefix } = {}, { context } = {}) { + context ||= await getRepositoryContext(); context.assertCurrent(); const syncRoot = getGoogleDriveSyncRoot(); const query = buildListFilesQuery(namePrefix, syncRoot); @@ -161,8 +173,9 @@ export async function listAppDataFiles({ namePrefix } = {}) { } const url = buildDriveUrl(DRIVE_FILES_ENDPOINT, null, params); - const response = await driveRequest(url); + const response = await driveRequest(url, {}, context); const data = await response.json(); + context.assertCurrent(); if (Array.isArray(data.files)) { files.push(...data.files); } @@ -172,16 +185,18 @@ export async function listAppDataFiles({ namePrefix } = {}) { return files; } -export async function downloadAppDataFile(fileId) { +export async function downloadAppDataFile(fileId, { context } = {}) { + context ||= await getRepositoryContext(); context.assertCurrent(); const syncRoot = getGoogleDriveSyncRoot(); const params = new URLSearchParams({ alt: 'media' }); appendFileAccessParams(params, syncRoot); const url = buildDriveUrl(DRIVE_FILES_ENDPOINT, fileId, params); - const response = await driveRequest(url); - return await response.text(); + const response = await driveRequest(url, {}, context); + const content = await response.text(); context.assertCurrent(); return content; } -export async function createAppDataFile({ name, content, mimeType, appProperties }) { +export async function createAppDataFile({ name, content, mimeType, appProperties }, { context } = {}) { + context ||= await getRepositoryContext(); context.assertCurrent(); const syncRoot = getGoogleDriveSyncRoot(); const metadata = { name, @@ -202,12 +217,13 @@ export async function createAppDataFile({ name, content, mimeType, appProperties 'Content-Type': `multipart/related; boundary=${boundary}`, }, body, - }); + }, context); - return await response.json(); + const result = await response.json(); context.assertCurrent(); return result; } -export async function updateAppDataFile({ fileId, content, mimeType, appProperties }) { +export async function updateAppDataFile({ fileId, content, mimeType, appProperties }, { context } = {}) { + context ||= await getRepositoryContext(); context.assertCurrent(); const syncRoot = getGoogleDriveSyncRoot(); const metadata = { appProperties, @@ -226,18 +242,19 @@ export async function updateAppDataFile({ fileId, content, mimeType, appProperti 'Content-Type': `multipart/related; boundary=${boundary}`, }, body, - }); + }, context); - return await response.json(); + const result = await response.json(); context.assertCurrent(); return result; } -export async function deleteAppDataFile(fileId) { +export async function deleteAppDataFile(fileId, { context } = {}) { + context ||= await getRepositoryContext(); context.assertCurrent(); const syncRoot = getGoogleDriveSyncRoot(); const params = new URLSearchParams(); appendFileAccessParams(params, syncRoot); const url = buildDriveUrl(DRIVE_FILES_ENDPOINT, fileId, params); await driveRequest(url, { method: 'DELETE', - }); + }, context); return { id: fileId }; } diff --git a/src/lib/sync/providers/storageProviders.js b/src/lib/sync/providers/storageProviders.js index e9ef9f7..102d311 100644 --- a/src/lib/sync/providers/storageProviders.js +++ b/src/lib/sync/providers/storageProviders.js @@ -27,12 +27,12 @@ function createUnsupportedProviderError(providerId) { const googleDriveStorageProvider = Object.freeze({ id: 'googleDrive', supportsCloudSync: true, - findFileByName: async (fileName) => await findGoogleAppDataFile(fileName), - listFiles: async ({ namePrefix } = {}) => await listGoogleAppDataFiles({ namePrefix }), - downloadFile: async (fileId) => await downloadGoogleAppDataFile(fileId), - createFile: async (payload) => await createGoogleAppDataFile(payload), - updateFile: async (payload) => await updateGoogleAppDataFile(payload), - deleteFile: async (fileId) => await deleteGoogleAppDataFile(fileId), + findFileByName: async (fileName, options) => await findGoogleAppDataFile(fileName, options), + listFiles: async ({ namePrefix } = {}, options) => await listGoogleAppDataFiles({ namePrefix }, options), + downloadFile: async (fileId, options) => await downloadGoogleAppDataFile(fileId, options), + createFile: async (payload, options) => await createGoogleAppDataFile(payload, options), + updateFile: async (payload, options) => await updateGoogleAppDataFile(payload, options), + deleteFile: async (fileId, options) => await deleteGoogleAppDataFile(fileId, options), }); const steamStorageProvider = Object.freeze({ diff --git a/src/lib/sync/snapshot.js b/src/lib/sync/snapshot.js index 26772d7..56b03be 100644 --- a/src/lib/sync/snapshot.js +++ b/src/lib/sync/snapshot.js @@ -1,333 +1,33 @@ -import { v4 as uuidv4 } from 'uuid'; -import { db } from '@/lib/db'; -import { buildLocalizedStringKey } from '@/lib/localization/stringTable'; - -function stripLocalOnlyProjectFields(project = {}) { - const sanitized = { ...(project || {}) }; - delete sanitized.lastExportPath; - delete sanitized.syncTimestamp; - return sanitized; -} - -function stripLocalOnlyDialogueFields(dialogue = {}) { - const sanitized = { ...(dialogue || {}) }; - delete sanitized.lastExportPath; - delete sanitized.syncTimestamp; - return sanitized; -} - -function dedupeBy(items = [], keySelector) { - const latestByKey = new Map(); - for (const item of items) { - const key = String(keySelector(item) || '').trim(); - if (!key) continue; - latestByKey.set(key, item); - } - return Array.from(latestByKey.values()); -} - -export async function buildProjectSnapshot(projectId) { - const project = await db.projects.get(projectId); - if (!project) { - throw new Error('Project not found'); - } - - const dialogues = await db.dialogues.where('projectId').equals(projectId).toArray(); - const participants = await db.participants.where('projectId').equals(projectId).toArray(); - const categories = await db.categories.where('projectId').equals(projectId).toArray(); - const decorators = await db.decorators.where('projectId').equals(projectId).toArray(); - const conditions = await db.conditions.where('projectId').equals(projectId).toArray(); - const localizedStrings = await db.localizedStrings - .where('projectId') - .equals(projectId) - .toArray(); - - const dialogueIds = dialogues.map((dialogue) => dialogue.id); - const nodes = dialogueIds.length - ? await db.nodes.where('dialogueId').anyOf(dialogueIds).toArray() - : []; - const edges = dialogueIds.length - ? await db.edges.where('dialogueId').anyOf(dialogueIds).toArray() - : []; - - return { - version: 2, - project: stripLocalOnlyProjectFields(project), - dialogues: dialogues.map((dialogue) => stripLocalOnlyDialogueFields(dialogue)), - participants, - categories, - decorators, - conditions, - localizedStrings, - nodes, - edges, - }; +import { getRepositoryContext, assertProjectReady } from '@/lib/db'; +import { canonicalizeProject, requireValidProject } from '@/lib/persistence/canonicalProject'; +import { readProjectState, prepareProjectCommit, commitPreparedProject } from '@/lib/persistence/projectRepository'; +import { remapProjectIdentities } from '@/lib/persistence/projectRemap'; + +export async function buildProjectSnapshot(projectId, { context: suppliedContext, requireReady = false } = {}) { + const context = suppliedContext || await getRepositoryContext(); + if (requireReady) await assertProjectReady(projectId, context); + const { snapshot } = await readProjectState(projectId, context); + const canonical = await canonicalizeProject(snapshot, { allowIncompleteMedia: !requireReady }); + context.assertCurrent(); + return requireReady ? requireValidProject(canonical) : canonical; } -export async function applyProjectSnapshot(snapshot) { - const { - project: rawProject, - dialogues = [], - participants = [], - categories = [], - decorators = [], - conditions = [], - localizedStrings = [], - nodes = [], - edges = [], - } = snapshot || {}; - const project = stripLocalOnlyProjectFields(rawProject); - const sanitizedDialogues = dedupeBy( - dialogues.map((dialogue) => stripLocalOnlyDialogueFields(dialogue)), - (dialogue) => dialogue?.id - ); - const sanitizedParticipants = dedupeBy(participants, (participant) => participant?.id); - const sanitizedCategories = dedupeBy(categories, (category) => category?.id); - const sanitizedDecorators = dedupeBy(decorators, (decorator) => decorator?.id); - const sanitizedConditions = dedupeBy(conditions, (condition) => condition?.id); - const normalizedLocalizedStrings = localizedStrings.map((entry) => { - const key = - entry?.key || - buildLocalizedStringKey({ - dialogueId: entry?.dialogueId, - nodeId: entry?.nodeId, - rowId: entry?.rowId, - field: entry?.field, - }); - return { - ...entry, - projectId: project.id, - key, - }; +export async function applyProjectSnapshot(snapshot, options = {}) { + const context = options.context || await getRepositoryContext(); + const current = await readProjectState(snapshot?.project?.id, context); + const prepared = await prepareProjectCommit(snapshot, { + context, expectedSequence: options.expectedSequence ?? current.sequence, + operation: options.operation || 'update', parentRevisionIds: options.parentRevisionIds, + revisionId: options.revisionId, enqueue: false, remoteRevision: options.remoteRevision, }); - const sanitizedLocalizedStrings = dedupeBy( - normalizedLocalizedStrings, - (entry) => `${project.id}::${entry?.key || ''}` - ); - const sanitizedNodes = dedupeBy( - nodes, - (node) => `${node?.dialogueId || ''}::${node?.id || ''}` - ); - const sanitizedEdges = dedupeBy( - edges, - (edge) => `${edge?.dialogueId || ''}::${edge?.id || ''}` - ); - - if (!project?.id) { - throw new Error('Invalid snapshot'); - } - - const syncTimestamp = new Date().toISOString(); - const syncedProject = { ...project, syncTimestamp }; - const syncedDialogues = sanitizedDialogues.map((dialogue) => ({ - ...dialogue, - syncTimestamp, - })); - const incomingDialogueIds = syncedDialogues - .map((dialogue) => String(dialogue?.id || '').trim()) - .filter(Boolean); - - await db.transaction( - 'rw', - [ - db.projects, - db.dialogues, - db.participants, - db.categories, - db.decorators, - db.conditions, - db.localizedStrings, - db.nodes, - db.edges, - ], - async () => { - if (incomingDialogueIds.length > 0) { - const conflictingDialogues = await db.dialogues - .where('id') - .anyOf(incomingDialogueIds) - .toArray(); - const conflictingDialogueIds = conflictingDialogues - .map((dialogue) => String(dialogue?.id || '').trim()) - .filter(Boolean); - if (conflictingDialogueIds.length > 0) { - await db.nodes.where('dialogueId').anyOf(conflictingDialogueIds).delete(); - await db.edges.where('dialogueId').anyOf(conflictingDialogueIds).delete(); - await db.localizedStrings.where('dialogueId').anyOf(conflictingDialogueIds).delete(); - await db.dialogues.bulkDelete(conflictingDialogueIds); - } - } - - const existingDialogues = await db.dialogues.where('projectId').equals(project.id).toArray(); - const existingDialogueIds = existingDialogues.map((dialogue) => dialogue.id); - if (existingDialogueIds.length > 0) { - await db.nodes.where('dialogueId').anyOf(existingDialogueIds).delete(); - await db.edges.where('dialogueId').anyOf(existingDialogueIds).delete(); - } - - await db.projects.delete(project.id); - await db.dialogues.where('projectId').equals(project.id).delete(); - await db.participants.where('projectId').equals(project.id).delete(); - await db.categories.where('projectId').equals(project.id).delete(); - await db.decorators.where('projectId').equals(project.id).delete(); - await db.conditions.where('projectId').equals(project.id).delete(); - await db.localizedStrings.where('projectId').equals(project.id).delete(); - - await db.projects.put(syncedProject); - - if (syncedDialogues.length > 0) await db.dialogues.bulkPut(syncedDialogues); - if (sanitizedParticipants.length > 0) await db.participants.bulkPut(sanitizedParticipants); - if (sanitizedCategories.length > 0) await db.categories.bulkPut(sanitizedCategories); - if (sanitizedDecorators.length > 0) await db.decorators.bulkPut(sanitizedDecorators); - if (sanitizedConditions.length > 0) await db.conditions.bulkPut(sanitizedConditions); - if (sanitizedLocalizedStrings.length > 0) - await db.localizedStrings.bulkPut(sanitizedLocalizedStrings); - if (sanitizedNodes.length > 0) await db.nodes.bulkPut(sanitizedNodes); - if (sanitizedEdges.length > 0) await db.edges.bulkPut(sanitizedEdges); - } - ); + const result = await commitPreparedProject(prepared); + return result.projectId; } -export async function applyProjectSnapshotAsNew(snapshot) { - const { - project: rawProject, - dialogues = [], - participants = [], - categories = [], - decorators = [], - conditions = [], - localizedStrings = [], - nodes = [], - edges = [], - } = snapshot || {}; - const project = stripLocalOnlyProjectFields(rawProject); - const sanitizedDialogues = dialogues.map((dialogue) => stripLocalOnlyDialogueFields(dialogue)); - - if (!project?.id) { - throw new Error('Invalid snapshot'); - } - - const now = new Date().toISOString(); - const newProjectId = uuidv4(); - - const newProject = { - ...project, - id: newProjectId, - createdAt: now, - modifiedAt: now, - }; - - const dialogueIdMap = new Map(); - const newDialogues = sanitizedDialogues.map((dialogue) => { - const newId = uuidv4(); - dialogueIdMap.set(dialogue.id, newId); - return { - ...dialogue, - id: newId, - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - }; - }); - - const categoryIdMap = new Map(); - const newCategories = categories.map((category) => { - const newId = uuidv4(); - categoryIdMap.set(category.id, newId); - return { - ...category, - id: newId, - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - parentCategoryId: category.parentCategoryId || null, - }; - }); - const normalizedCategories = newCategories.map((category) => ({ - ...category, - parentCategoryId: category.parentCategoryId - ? categoryIdMap.get(category.parentCategoryId) || null - : null, - })); - - const newParticipants = participants.map((participant) => ({ - ...participant, - id: uuidv4(), - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - })); - - const newDecorators = decorators.map((decorator) => ({ - ...decorator, - id: uuidv4(), - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - })); - const newConditions = conditions.map((condition) => ({ - ...condition, - id: uuidv4(), - projectId: newProjectId, - createdAt: now, - modifiedAt: now, - })); - - const newNodes = nodes.map((node) => ({ - ...node, - dialogueId: dialogueIdMap.get(node.dialogueId) || node.dialogueId, - })); - - const newEdges = edges.map((edge) => ({ - ...edge, - dialogueId: dialogueIdMap.get(edge.dialogueId) || edge.dialogueId, - })); - - const newLocalizedStrings = localizedStrings.map((entry) => { - const mappedDialogueId = dialogueIdMap.get(entry.dialogueId) || entry.dialogueId || ''; - const key = buildLocalizedStringKey({ - dialogueId: mappedDialogueId, - nodeId: entry.nodeId, - rowId: entry.rowId, - field: entry.field, - }); - return { - ...entry, - projectId: newProjectId, - dialogueId: mappedDialogueId, - key: key || entry.key, - createdAt: now, - modifiedAt: now, - }; - }); - - await db.transaction( - 'rw', - [ - db.projects, - db.dialogues, - db.participants, - db.categories, - db.decorators, - db.conditions, - db.localizedStrings, - db.nodes, - db.edges, - ], - async () => { - await db.projects.add(newProject); - if (newDialogues.length > 0) await db.dialogues.bulkAdd(newDialogues); - if (newParticipants.length > 0) await db.participants.bulkAdd(newParticipants); - if (normalizedCategories.length > 0) await db.categories.bulkAdd(normalizedCategories); - if (newDecorators.length > 0) await db.decorators.bulkAdd(newDecorators); - if (newConditions.length > 0) await db.conditions.bulkAdd(newConditions); - if (newLocalizedStrings.length > 0) await db.localizedStrings.bulkAdd(newLocalizedStrings); - if (newNodes.length > 0) await db.nodes.bulkAdd(newNodes); - if (newEdges.length > 0) await db.edges.bulkAdd(newEdges); - } - ); - - return newProjectId; +export async function applyProjectSnapshotAsNew(snapshot, options = {}) { + const context = options.context || await getRepositoryContext(); + const { snapshot: clone } = remapProjectIdentities(await canonicalizeProject(snapshot), { projectId: options.projectId }); + if (options.name) clone.project.name = options.name; + const prepared = await prepareProjectCommit(clone, { context, expectedSequence: 0, operation: 'create' }); + return (await commitPreparedProject(prepared)).projectId; } - - - diff --git a/src/lib/sync/steamCloudClient.js b/src/lib/sync/steamCloudClient.js index d4b80d8..e538f0f 100644 --- a/src/lib/sync/steamCloudClient.js +++ b/src/lib/sync/steamCloudClient.js @@ -1,4 +1,4 @@ -import { getActiveProfileId, setActiveProfileId } from '@/lib/profile/activeProfile'; +import { getRepositoryContext } from '@/lib/db'; function getElectronApi() { if (typeof window === 'undefined') return null; @@ -29,46 +29,13 @@ function assertSteamSyncApi() { return electronApi; } -function sanitizeProfileId(rawValue) { - const value = String(rawValue || '').trim(); - if (!value) return 'local'; - return value.replace(/[^a-zA-Z0-9_-]/g, '-'); -} - -function buildSteamProfileId(steamId) { - const normalizedSteamId = String(steamId || '').trim(); - if (!normalizedSteamId) return ''; - return sanitizeProfileId(`steam-${normalizedSteamId}`); -} - async function resolveSteamSyncProfileId(electronApi) { - const activeProfileId = sanitizeProfileId(getActiveProfileId()); - if (activeProfileId.startsWith('steam-') && activeProfileId !== 'steam-') { - return activeProfileId; - } - - try { - if (typeof electronApi?.getSteamStatus === 'function') { - const steamStatus = await electronApi.getSteamStatus(); - const isAvailable = Boolean(steamStatus?.available); - const steamProfileId = buildSteamProfileId(steamStatus?.steamId); - if (isAvailable && steamProfileId) { - if (activeProfileId !== steamProfileId) { - setActiveProfileId(steamProfileId); - console.info( - `[steam-sync] Corrected active profile from "${activeProfileId}" to "${steamProfileId}"` - ); - } - return steamProfileId; - } - } - } catch (error) { - console.warn('[steam-sync] Failed to resolve Steam profile id:', error); - } - - return activeProfileId; + const context = await getRepositoryContext(); + const status = await electronApi.getSteamStatus(); + context.assertCurrent(); + if (!status?.available || context.profileId !== `steam-${status.steamId}`) throw new Error('Steam profile is not active'); + return context.profileId; } - export async function findSteamCloudFile(fileName) { const electronApi = assertSteamSyncApi(); const profileId = await resolveSteamSyncProfileId(electronApi); @@ -100,8 +67,8 @@ export async function createSteamCloudFile(payload) { const electronApi = assertSteamSyncApi(); const profileId = await resolveSteamSyncProfileId(electronApi); return await electronApi.steamSyncCreateFile({ - profileId, ...payload, + profileId, }); } @@ -109,8 +76,8 @@ export async function updateSteamCloudFile(payload) { const electronApi = assertSteamSyncApi(); const profileId = await resolveSteamSyncProfileId(electronApi); return await electronApi.steamSyncUpdateFile({ - profileId, ...payload, + profileId, }); } diff --git a/src/lib/sync/syncStorage.js b/src/lib/sync/syncStorage.js index 675e0cd..9f1071f 100644 --- a/src/lib/sync/syncStorage.js +++ b/src/lib/sync/syncStorage.js @@ -1,46 +1,39 @@ -import { db } from '@/lib/db'; +import { getRepositoryContext } from '@/lib/db'; +import { readAccount, writeAccount, removeAccount } from '@/lib/sync/credentialStore'; import { getActiveProfileId } from '@/lib/profile/activeProfile'; import { SYNC_TOMBSTONE_TTL_MS } from '@/lib/sync/core/constants'; -export async function getSyncAccount(provider) { - if (!provider) return null; - return await db.syncAccounts.get(provider); +export async function getSyncAccount(provider, context = null) { + return provider ? readAccount(provider, context) : null; } - -export async function upsertSyncAccount(provider, data) { - if (!provider) return null; - const payload = { - provider, - ...data, - }; - await db.syncAccounts.put(payload); - return payload; +export async function upsertSyncAccount(provider, data, context = null) { + return provider ? writeAccount(provider, data, context) : null; } - -export async function clearSyncAccount(provider) { - if (!provider) return; - await db.syncAccounts.delete(provider); +export async function clearSyncAccount(provider, context = null) { + if (provider) await removeAccount(provider, context); } - export async function getSyncProject(projectId, provider) { + const context = await getRepositoryContext(); context.assertCurrent(); if (!projectId || !provider) return null; - return await db.syncProjects.get([projectId, provider]); + return await context.db.syncProjects.get([projectId, provider]); } export async function upsertSyncProject(projectId, provider, data) { + const context = await getRepositoryContext(); context.assertCurrent(); if (!projectId || !provider) return null; const payload = { projectId, provider, ...data, }; - await db.syncProjects.put(payload); + await context.db.syncProjects.put(payload); return payload; } export async function clearSyncProject(projectId, provider) { + const context = await getRepositoryContext(); context.assertCurrent(); if (!projectId || !provider) return; - await db.syncProjects.delete([projectId, provider]); + await context.db.syncProjects.delete([projectId, provider]); } function sanitizeProfileId(value) { @@ -69,18 +62,16 @@ function normalizeIsoDate(value, fallbackIso = '') { return fallbackIso; } -function isExpiredTombstone(entry, nowMs = Date.now()) { - const expiresAtMs = entry?.expiresAt ? Date.parse(entry.expiresAt) : 0; - return Boolean(expiresAtMs) && expiresAtMs <= nowMs; -} export async function getSyncCatalogState(provider, profileId = '') { + const context = await getRepositoryContext(); context.assertCurrent(); if (!provider) return null; const normalizedProfileId = resolveProfileId(profileId); - return await db.syncCatalogState.get([provider, normalizedProfileId]); + return await context.db.syncCatalogState.get([provider, normalizedProfileId]); } export async function upsertSyncCatalogState(provider, profileId = '', data = {}) { + const context = await getRepositoryContext(); context.assertCurrent(); if (!provider) return null; const normalizedProfileId = resolveProfileId(profileId); const payload = { @@ -88,27 +79,30 @@ export async function upsertSyncCatalogState(provider, profileId = '', data = {} profileId: normalizedProfileId, ...data, }; - await db.syncCatalogState.put(payload); + await context.db.syncCatalogState.put(payload); return payload; } export async function listSyncCatalogStates(provider = '') { + const context = await getRepositoryContext(); context.assertCurrent(); const providerId = String(provider || '').trim(); if (!providerId) { - return await db.syncCatalogState.toArray(); + return await context.db.syncCatalogState.toArray(); } - return await db.syncCatalogState.where('provider').equals(providerId).toArray(); + return await context.db.syncCatalogState.where('provider').equals(providerId).toArray(); } export async function getSyncTombstone({ provider, entityType, entityId } = {}) { + const context = await getRepositoryContext(); context.assertCurrent(); const providerId = String(provider || '').trim(); const normalizedEntityType = normalizeEntityType(entityType); const normalizedEntityId = String(entityId || '').trim(); if (!providerId || !normalizedEntityType || !normalizedEntityId) return null; - return await db.syncTombstones.get([providerId, normalizedEntityType, normalizedEntityId]); + return await context.db.syncTombstones.get([providerId, normalizedEntityType, normalizedEntityId]); } export async function upsertSyncTombstone(input = {}) { + const context = await getRepositoryContext(); context.assertCurrent(); const providerId = String(input?.provider || '').trim(); const entityType = normalizeEntityType(input?.entityType); const entityId = String(input?.entityId || '').trim(); @@ -135,7 +129,8 @@ export async function upsertSyncTombstone(input = {}) { updatedAt: nowIso, }; - await db.syncTombstones.put(payload); + context.assertCurrent(); + await context.db.syncTombstones.put(payload); return payload; } @@ -145,6 +140,7 @@ export async function acknowledgeSyncTombstone({ entityId, acknowledgedAt = '', } = {}) { + const context = await getRepositoryContext(); context.assertCurrent(); const existing = await getSyncTombstone({ provider, entityType, entityId }); if (!existing) return null; const ackAt = @@ -155,60 +151,45 @@ export async function acknowledgeSyncTombstone({ acknowledgedAt: ackAt, updatedAt: ackAt, }; - await db.syncTombstones.put(payload); + context.assertCurrent(); + await context.db.syncTombstones.put(payload); return payload; } export async function listSyncTombstones(options = {}) { + const context = await getRepositoryContext(); context.assertCurrent(); const providerId = String(options?.provider || '').trim(); const entityType = normalizeEntityType(options?.entityType); const entityId = String(options?.entityId || '').trim(); const projectId = String(options?.projectId || '').trim(); const pendingOnly = Boolean(options?.pendingOnly); - const includeExpired = options?.includeExpired !== false; let items = []; if (providerId) { - items = await db.syncTombstones.where('provider').equals(providerId).toArray(); + items = await context.db.syncTombstones.where('provider').equals(providerId).toArray(); } else if (entityType) { - items = await db.syncTombstones.where('entityType').equals(entityType).toArray(); + items = await context.db.syncTombstones.where('entityType').equals(entityType).toArray(); } else if (entityId) { - items = await db.syncTombstones.where('entityId').equals(entityId).toArray(); + items = await context.db.syncTombstones.where('entityId').equals(entityId).toArray(); } else { - items = await db.syncTombstones.toArray(); + items = await context.db.syncTombstones.toArray(); } - const nowMs = Date.now(); return items.filter((item) => { if (entityType && item.entityType !== entityType) return false; if (entityId && item.entityId !== entityId) return false; if (projectId && String(item.projectId || '').trim() !== projectId) return false; if (pendingOnly && !item.pending) return false; - if (!includeExpired && isExpiredTombstone(item, nowMs)) return false; return true; }); } -export async function clearSyncTombstone({ provider, entityType, entityId } = {}) { - const providerId = String(provider || '').trim(); - const normalizedEntityType = normalizeEntityType(entityType); - const normalizedEntityId = String(entityId || '').trim(); - if (!providerId || !normalizedEntityType || !normalizedEntityId) return; - await db.syncTombstones.delete([providerId, normalizedEntityType, normalizedEntityId]); +export async function clearSyncTombstone() { + throw new Error('Deletion evidence is retained indefinitely.'); } -export async function clearSyncTombstonesByEntity({ entityType, entityId } = {}) { - const normalizedEntityType = normalizeEntityType(entityType); - const normalizedEntityId = String(entityId || '').trim(); - if (!normalizedEntityType || !normalizedEntityId) return; - const matches = await listSyncTombstones({ - entityType: normalizedEntityType, - entityId: normalizedEntityId, - }); - if (!matches.length) return; - await db.syncTombstones.bulkDelete( - matches.map((item) => [item.provider, item.entityType, item.entityId]) - ); +export async function clearSyncTombstonesByEntity() { + throw new Error('Deletion evidence is retained indefinitely.'); } export async function hasActiveTombstone({ entityType, entityId, providers = [] } = {}) { diff --git a/src/stores/syncStore.js b/src/stores/syncStore.js index a1b7445..bc056fa 100644 --- a/src/stores/syncStore.js +++ b/src/stores/syncStore.js @@ -1,4 +1,6 @@ import { create } from 'zustand'; +import { getRepositoryContext } from '@/lib/db'; +import { credentialCapabilities, readSecret, writeSecret } from '@/lib/sync/credentialStore'; import { createJSONStorage, persist } from 'zustand/middleware'; import { startGoogleDriveAuth, @@ -8,24 +10,11 @@ import { setStoredClientId, getConfiguredClientId, } from '@/lib/sync/googleDriveAuth'; -import { - checkRemoteDiff as checkRemoteDiffEngine, - diffRemoteLocal as diffRemoteLocalEngine, - previewPullFromFile, - previewPushProject, - pullProjectFromFile, - pullProject, - deleteLocalProject as deleteLocalProjectEngine, - pushProject as pushProjectToRemote, - publishTombstone as publishTombstoneEngine, - syncAllProjects as syncAllProjectsEngine, -} from '@/lib/sync/syncEngine'; +import { syncRevisions, resolveRevisionConflict } from '@/lib/sync/core/revisionProtocol'; import { getSyncAccount, upsertSyncAccount, clearSyncAccount, - upsertSyncTombstone, - listPendingSyncTombstones, } from '@/lib/sync/syncStorage'; import { SYNC_PROVIDER_IDS, @@ -39,7 +28,7 @@ const SYNC_STORAGE_KEY = 'mountea-dialoguer-sync'; const DEFAULT_PROVIDER_INPUT = Object.freeze({ accountLabel: '', passphrase: '', - rememberPassphrase: true, + rememberPassphrase: false, }); const DEFAULT_PULL_STATE = Object.freeze({ active: false, @@ -51,11 +40,28 @@ const DEFAULT_PULL_STATE = Object.freeze({ total: 0, }); -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const pushQueue = new Map(); + +// One mounted worker retries durable intent. A busy/offline interval never queues +// another run, and reconnect bursts share the same minimum retry interval. +export function startSyncRetryWorker({ eventTarget = window, intervalMs = 30000, syncOptions = {} } = {}) { + let stopped = false, inFlight = false, lastAttempt = -Infinity; + const tick = async () => { + const state = useSyncStore.getState(); + if (stopped || inFlight || navigator.onLine === false || !['connected', 'error'].includes(state.status) || state.syncMode === 'list' || Date.now() - lastAttempt < intervalMs) return; + lastAttempt = Date.now(); inFlight = true; + try { await state.syncAllProjects({ ...syncOptions, mode: state.syncMode, trigger: 'durable-retry' }); } + finally { inFlight = false; } + }; + const timer = setInterval(() => { void tick(); }, intervalMs); + eventTarget.addEventListener('online', tick); + return () => { stopped = true; clearInterval(timer); eventTarget.removeEventListener('online', tick); }; +} + const STEAM_CONNECT_SYNC_COOLDOWN_MS = 5000; let steamConnectSyncInFlight = false; let lastSteamConnectSyncAt = 0; +let activeAuthController = null; const NOOP_PROFILE_STORAGE = Object.freeze({ getItem: () => null, @@ -111,7 +117,7 @@ function createProviderInput(input = {}) { accountLabel: String(input?.accountLabel || ''), passphrase: String(input?.passphrase || ''), rememberPassphrase: - input?.rememberPassphrase === undefined ? true : Boolean(input.rememberPassphrase), + input?.rememberPassphrase === undefined ? false : Boolean(input.rememberPassphrase), }; } @@ -178,7 +184,7 @@ function createPersistedProviderInputs(providerInputs) { const input = normalized[providerId]; persisted[providerId] = { ...input, - passphrase: input.rememberPassphrase ? input.passphrase : '', + passphrase: '', }; } return persisted; @@ -195,15 +201,6 @@ function canUseCloudSyncProvider(providerId) { return Boolean(providerId) && supportsCloudSync(providerId); } -function canRunSyncForStatus(providerId, status) { - const normalizedProvider = normalizeProviderId(providerId); - if (normalizedProvider === 'steam') { - // Steam provider should remain usable even if Google auth set a global error. - return status !== 'syncing'; - } - return status === 'connected'; -} - export const useSyncStore = create( persist( (set, get) => ({ @@ -217,6 +214,7 @@ export const useSyncStore = create( loginDialogOpen: false, syncMode: 'pull', hasHydrated: false, + canRememberCredentials: false, pullState: createResetPullState('project'), getProviderInput: (providerId) => { @@ -260,10 +258,21 @@ export const useSyncStore = create( clearError: () => set({ error: null }), setHasHydrated: () => set({ hasHydrated: true }), setHideLoginPrompt: (value) => set({ hideLoginPrompt: value }), - setLoginDialogOpen: (value) => set({ loginDialogOpen: value }), + cancelAuthentication: () => activeAuthController?.abort(), + setLoginDialogOpen: (value) => { if (!value) activeAuthController?.abort(); set({ loginDialogOpen: value }); }, setSyncMode: (value) => set({ syncMode: value }), loadAccount: async (options = {}) => { + const context = options.context || await getRepositoryContext(); + const capability = await credentialCapabilities(); + context.assertCurrent(); + const legacyPassphrase = getProviderPassphraseFromState(get(), 'googleDrive'); + if (legacyPassphrase && capability.canRemember && getProviderInputFromState(get(), 'googleDrive').rememberPassphrase) { + await writeSecret('googleDrive-passphrase', legacyPassphrase, { remember: true, context }); + } + const savedPassphrase = await readSecret('googleDrive-passphrase', context); + context.assertCurrent(); + set((state) => ({ canRememberCredentials: capability.canRemember, providerInputs: withProviderInput(state, 'googleDrive', { passphrase: savedPassphrase || legacyPassphrase, rememberPassphrase: capability.canRemember && getProviderInputFromState(state, 'googleDrive').rememberPassphrase }) })); const steamStatus = options?.steamStatus || null; const preferredProvider = normalizeProviderId(get().provider); const steamAvailable = Boolean(steamStatus?.available); @@ -272,7 +281,8 @@ export const useSyncStore = create( const steamIdentity = String( steamStatus?.personaName || steamStatus?.steamId || '' ); - const account = await getSyncAccount('googleDrive'); + const account = await getSyncAccount('googleDrive', context); + context.assertCurrent(); const shouldPreferSteam = steamAvailable && (steamChannel || preferredProvider === 'steam'); @@ -384,6 +394,7 @@ export const useSyncStore = create( }, connectGoogleDrive: async () => { + const context = await getRepositoryContext(); const stateBeforeAuth = get(); const passphrase = getProviderPassphraseFromState(get(), 'googleDrive'); try { @@ -404,17 +415,25 @@ export const useSyncStore = create( return false; } + activeAuthController?.abort(); + const controller = new AbortController(); + activeAuthController = controller; + const abortForProfile = () => controller.abort(); + context.signal.addEventListener('abort', abortForProfile, { once: true }); set({ status: 'connecting', error: null }); try { traceSyncEvent('AUTH_START', { provider: 'googleDrive' }); - const authResult = await startGoogleDriveAuth(); + const authResult = await startGoogleDriveAuth({ signal: controller.signal }); + context.assertCurrent(); const accessToken = authResult.accessToken; const expiresIn = Number(authResult.expiresIn || 3600); const expiresAt = Date.now() + expiresIn * 1000; const userInfo = await fetchUserInfo(accessToken); + context.assertCurrent(); + if (controller.signal.aborted) throw new DOMException('Authentication cancelled', 'AbortError'); - const existing = await getSyncAccount('googleDrive'); + const existing = await getSyncAccount('googleDrive', context); await upsertSyncAccount('googleDrive', { accountId: userInfo.sub || userInfo.id || '', email: userInfo.email || '', @@ -423,7 +442,9 @@ export const useSyncStore = create( expiresAt, scope: authResult.scope, tokenType: authResult.tokenType, - }); + }, context); + await writeSecret('googleDrive-passphrase', passphrase, { remember: get().canRememberCredentials && getProviderInputFromState(get(), 'googleDrive').rememberPassphrase, context }); + context.assertCurrent(); set((state) => ({ provider: 'googleDrive', @@ -439,7 +460,8 @@ export const useSyncStore = create( }); return true; } catch (error) { - console.error('Google Drive connect failed:', error); + if (context.signal.aborted) return false; + if (controller.signal.aborted) { set({ status: stateBeforeAuth.status, error: null }); return false; } const message = (error?.message || '').toLowerCase(); let errorCode = 'oauthFailed'; if (message.includes('popup')) errorCode = 'popupBlocked'; @@ -457,6 +479,9 @@ export const useSyncStore = create( }; }); return false; + } finally { + context.signal.removeEventListener('abort', abortForProfile); + if (activeAuthController === controller) activeAuthController = null; } }, @@ -478,813 +503,72 @@ export const useSyncStore = create( })); }, - processPendingTombstones: async (providerId = null) => { - const state = get(); - const resolvedProviderId = normalizeProviderId( - providerId || resolveCloudProviderId(state) - ); - if (!resolvedProviderId || !canUseCloudSyncProvider(resolvedProviderId)) { - return { processed: 0, pending: 0 }; - } - - const pending = await listPendingSyncTombstones(resolvedProviderId); - if (!pending.length) { - return { processed: 0, pending: 0 }; - } - - traceSyncEvent('TOMBSTONE_SWEEP_START', { - provider: resolvedProviderId, - count: pending.length, - }); - - let processed = 0; - for (const entry of pending) { - const entityType = String(entry?.entityType || 'project').trim().toLowerCase(); - const entityId = String(entry?.entityId || entry?.projectId || '').trim(); - if (!entityId) continue; - try { - await publishTombstoneEngine({ - provider: resolvedProviderId, - entityType, - entityId, - projectId: String(entry?.projectId || '').trim() || '', - deletedAt: entry?.deletedAt, - expiresAt: entry?.expiresAt, - }); - processed += 1; - traceSyncEvent('TOMBSTONE_SWEEP_ITEM_DONE', { - provider: resolvedProviderId, - entityType, - entityId, - }); - } catch (error) { - traceSyncEvent('TOMBSTONE_SWEEP_ITEM_ERROR', { - provider: resolvedProviderId, - entityType, - entityId, - message: String(error?.message || error), - }); - } - } - - traceSyncEvent('TOMBSTONE_SWEEP_DONE', { - provider: resolvedProviderId, - processed, - pending: pending.length - processed, - }); - return { processed, pending: pending.length - processed }; - }, - - scheduleProjectDeletion: async (projectId, options = {}) => { - const normalizedProjectId = String(projectId || '').trim(); - if (!normalizedProjectId) return; - - const currentState = get(); - const explicitProviders = Array.isArray(options?.providers) - ? options.providers.map((id) => normalizeProviderId(id)).filter(Boolean) - : []; - const activeCloudProviderId = resolveCloudProviderId(currentState); - const providers = Array.from( - new Set([ - ...explicitProviders, - ...(activeCloudProviderId ? [activeCloudProviderId] : []), - ]) - ).filter((id) => canUseCloudSyncProvider(id)); - - if (!providers.length) { - traceSyncEvent('DELETE_QUEUE_SKIP', { - projectId: normalizedProjectId, - reason: 'no-provider', - }); - return; - } - - const deletedAt = new Date().toISOString(); - for (const providerId of providers) { - await upsertSyncTombstone({ - provider: providerId, - entityType: 'project', - entityId: normalizedProjectId, - projectId: normalizedProjectId, - deletedAt, - pending: true, - }); - traceSyncEvent('TOMBSTONE_QUEUED', { - entityType: 'project', - entityId: normalizedProjectId, - provider: providerId, - }); - } - - for (const providerId of providers) { - try { - await get().processPendingTombstones(providerId); - } catch (error) { - traceSyncEvent('TOMBSTONE_SWEEP_ITEM_ERROR', { - provider: providerId, - entityType: 'project', - entityId: normalizedProjectId, - message: String(error?.message || error), - }); - } - } - - if ( - activeCloudProviderId && - providers.includes(activeCloudProviderId) && - canRunSyncForStatus(activeCloudProviderId, currentState.status) - ) { - await get().syncAllProjects({ - mode: 'full', - trigger: 'project-delete', - }); - } - }, - - scheduleDialogueDeletion: async (dialogueId, projectId, options = {}) => { - const normalizedDialogueId = String(dialogueId || '').trim(); - const normalizedProjectId = String(projectId || '').trim(); - if (!normalizedDialogueId || !normalizedProjectId) return; - - const currentState = get(); - const explicitProviders = Array.isArray(options?.providers) - ? options.providers.map((id) => normalizeProviderId(id)).filter(Boolean) - : []; - const activeCloudProviderId = resolveCloudProviderId(currentState); - const providers = Array.from( - new Set([ - ...explicitProviders, - ...(activeCloudProviderId ? [activeCloudProviderId] : []), - ]) - ).filter((id) => canUseCloudSyncProvider(id)); - - if (!providers.length) { - traceSyncEvent('TOMBSTONE_QUEUE_SKIP', { - entityType: 'dialogue', - entityId: normalizedDialogueId, - reason: 'no-provider', - }); - return; - } - - const deletedAt = new Date().toISOString(); - for (const providerId of providers) { - await upsertSyncTombstone({ - provider: providerId, - entityType: 'dialogue', - entityId: normalizedDialogueId, - projectId: normalizedProjectId, - deletedAt, - pending: true, - }); - traceSyncEvent('TOMBSTONE_QUEUED', { - entityType: 'dialogue', - entityId: normalizedDialogueId, - provider: providerId, - }); - } - - for (const providerId of providers) { - try { - await get().processPendingTombstones(providerId); - } catch (error) { - traceSyncEvent('TOMBSTONE_SWEEP_ITEM_ERROR', { - provider: providerId, - entityType: 'dialogue', - entityId: normalizedDialogueId, - message: String(error?.message || error), - }); - } - } - - if ( - activeCloudProviderId && - providers.includes(activeCloudProviderId) && - canRunSyncForStatus(activeCloudProviderId, currentState.status) - ) { - await get().syncAllProjects({ - mode: 'full', - trigger: 'dialogue-delete', - }); - } - }, - - syncAllProjects: async (options = {}) => { - const syncStartedAt = Date.now(); - const currentState = get(); - const { status, provider, pullState, syncMode } = currentState; - const cloudProviderId = resolveCloudProviderId(currentState); - const passphrase = resolveSyncPassphrase(currentState, provider); - const providerConfig = getSyncProviderConfig(provider); - const requiresPassphrase = providerConfig?.requiresPassphrase !== false; - if (!provider) { - traceSyncEvent('SKIP', { reason: 'missing-provider' }); - return; - } - if (!cloudProviderId) { - traceSyncEvent('SKIP', { - reason: 'provider-not-cloud-capable', - provider, - }); - return; - } - if (status === 'syncing') { - traceSyncEvent('SKIP', { - reason: 'already-syncing', - provider: cloudProviderId, - }); - return; - } - if (!canRunSyncForStatus(cloudProviderId, status)) { - traceSyncEvent('SKIP', { - reason: 'provider-not-connected', - provider: cloudProviderId, - status, - }); - return; - } - if (requiresPassphrase && (!passphrase || !passphrase.trim())) { - traceSyncEvent('SKIP', { - reason: 'missing-passphrase', - provider: cloudProviderId, - }); - return; - } - if (pullState?.active && pullState?.mode === 'project') { - traceSyncEvent('SKIP', { - reason: 'project-pull-active', - provider: cloudProviderId, - }); - return; - } - const { mode = syncMode || 'full', trigger = 'unknown' } = options; - traceSyncEvent('START', { - mode, - trigger, - provider: cloudProviderId, - }); - - set({ status: 'syncing' }); - const resetPullState = createResetPullState('bulk'); - let shouldShow = false; - let total = 0; - const providerPassphrases = cloudProviderId - ? { [cloudProviderId]: resolveSyncPassphrase(currentState, cloudProviderId) } - : {}; - const engineProviders = cloudProviderId ? [cloudProviderId] : []; - try { - await get().processPendingTombstones(cloudProviderId); - - if (mode === 'list') { - console.log('[sync] Listing remote projects (debug)'); - const diff = await diffRemoteLocalEngine({ provider: cloudProviderId }); - console.log('[sync] Remote projects (raw)', diff.remoteRaw); - const duplicateInfo = Array.from(diff.duplicates.entries()).map( - ([projectId, items]) => ({ - projectId, - count: items.length, - }) - ); - if (duplicateInfo.length > 0) { - console.warn('[sync] Duplicate remote files detected', duplicateInfo); - } - - console.log('[sync] Remote/local comparison', diff.comparisons); - console.log('[sync] Actions (would pull/push)', diff.actions); - const previewPulls = []; - const previewPushes = []; - for (const item of diff.comparisons) { - if (item.decision === 'pull' && item.remote?.fileId) { - try { - const preview = await previewPullFromFile({ - projectId: item.projectId, - fileId: item.remote.fileId, - revision: item.remote.revision, - passphrase, - provider: cloudProviderId, - }); - previewPulls.push(preview); - } catch (error) { - console.error('[sync] Preview pull failed', item.projectId, error); - } - } - } - - for (const projectId of diff.actions.toPush) { - try { - const preview = await previewPushProject({ projectId }); - previewPushes.push(preview); - } catch (error) { - console.error('[sync] Preview push failed', projectId, error); - } - } - - if (previewPulls.length > 0) { - console.log('[sync] Preview pulled snapshots', previewPulls); - } else { - console.log('[sync] No pull previews available'); - } - - if (previewPushes.length > 0) { - console.log('[sync] Preview push snapshots', previewPushes); - } else { - console.log('[sync] No push previews available'); - } - set({ status: 'connected', pullState: resetPullState }); - return; - } - - if (mode === 'push') { - console.log('[sync] Push-only sync start'); - const diffStartedAt = Date.now(); - traceSyncEvent('PUSH_DIFF_START', { provider: cloudProviderId }); - const diff = await diffRemoteLocalEngine({ provider: cloudProviderId }); - traceSyncEvent('PUSH_DIFF_DONE', { - elapsedMs: Date.now() - diffStartedAt, - toPull: diff.actions.toPull.length, - toPush: diff.actions.toPush.length, - remoteOnly: diff.actions.remoteOnly.length, - localOnly: diff.actions.localOnly.length, - }); - console.log('[sync] Remote projects (raw)', diff.remoteRaw); - const duplicateInfo = Array.from(diff.duplicates.entries()).map( - ([projectId, items]) => ({ - projectId, - count: items.length, - }) - ); - if (duplicateInfo.length > 0) { - console.warn('[sync] Duplicate remote files detected', duplicateInfo); - } - if (diff.actions.toPull.length > 0) { - console.log('[sync] Remote is newer for', diff.actions.toPull); - } - if (diff.actions.toPush.length === 0) { - console.log('[sync] No projects to push'); - traceSyncEvent('NOOP', { - mode: 'push', - reason: 'nothing-to-push', - elapsedMs: Date.now() - syncStartedAt, - }); - set({ status: 'connected', pullState: resetPullState }); - return; - } - - for (const projectId of diff.actions.toPush) { - try { - console.log('[sync] Pushing project', projectId); - traceSyncEvent('PUSH_PROJECT_START', { projectId }); - await pushProjectToRemote({ - projectId, - passphrase, - provider: cloudProviderId, - }); - traceSyncEvent('PUSH_PROJECT_DONE', { projectId }); - } catch (error) { - console.error('[sync] Push failed', projectId, error); - traceSyncEvent('PUSH_PROJECT_ERROR', { - projectId, - message: String(error?.message || error), - }); - } - } - console.log('[sync] Push-only sync complete'); - traceSyncEvent('COMPLETE', { - mode: 'push', - elapsedMs: Date.now() - syncStartedAt, - }); - set({ - status: 'connected', - lastSyncedAt: new Date().toISOString(), - pullState: resetPullState, - }); - return; - } - - if (mode === 'pull') { - console.log('[sync] Pull-only sync start'); - const diffStartedAt = Date.now(); - traceSyncEvent('PULL_DIFF_START', { provider: cloudProviderId }); - const diff = await diffRemoteLocalEngine({ provider: cloudProviderId }); - const localDeletes = Array.isArray(diff.actions.toDeleteLocal) - ? diff.actions.toDeleteLocal - : []; - traceSyncEvent('PULL_DIFF_DONE', { - elapsedMs: Date.now() - diffStartedAt, - toPull: diff.actions.toPull.length, - toPush: diff.actions.toPush.length, - toDeleteLocal: localDeletes.length, - remoteOnly: diff.actions.remoteOnly.length, - localOnly: diff.actions.localOnly.length, - }); - const duplicateInfo = Array.from(diff.duplicates.entries()).map( - ([projectId, items]) => ({ - projectId, - count: items.length, - }) - ); - if (duplicateInfo.length > 0) { - console.warn('[sync] Duplicate remote files detected', duplicateInfo); - } - - if (diff.actions.toPull.length === 0 && localDeletes.length === 0) { - console.log('[sync] No projects to pull or prune locally'); - traceSyncEvent('NOOP', { - mode: 'pull', - reason: 'nothing-to-pull', - elapsedMs: Date.now() - syncStartedAt, - }); - set({ status: 'connected', pullState: resetPullState }); - return; - } - - const comparisonMap = new Map( - diff.comparisons.map((item) => [item.projectId, item]) - ); - const startedAt = Date.now(); - const total = diff.actions.toPull.length + localDeletes.length; - let index = 0; - - set({ - pullState: { - active: true, - step: 'checking', - progress: 10, - projectId: null, - mode: 'bulk', - current: 0, - total, - }, - }); - - for (const projectId of localDeletes) { - index += 1; - const deleteStartedAt = Date.now(); - traceSyncEvent('PULL_PROJECT_DELETE_LOCAL_START', { - projectId, - index, - total, - }); - - set({ - pullState: { - active: true, - step: 'applying', - progress: Math.min(95, Math.round((index / total) * 90)), - projectId, - mode: 'bulk', - current: index, - total, - }, - }); - - try { - const result = await deleteLocalProjectEngine({ - projectId, - provider: cloudProviderId, - }); - console.log('[sync] Pruned local project missing remotely', result); - traceSyncEvent('PULL_PROJECT_DELETE_LOCAL_DONE', { - projectId, - index, - total, - elapsedMs: Date.now() - deleteStartedAt, - }); - } catch (error) { - console.error('[sync] Local prune failed', projectId, error); - traceSyncEvent('PULL_PROJECT_DELETE_LOCAL_ERROR', { - projectId, - index, - total, - elapsedMs: Date.now() - deleteStartedAt, - message: String(error?.message || error), - }); - } - } - - for (const projectId of diff.actions.toPull) { - index += 1; - const comparison = comparisonMap.get(projectId); - const remote = comparison?.remote; - const pullProjectStartedAt = Date.now(); - traceSyncEvent('PULL_PROJECT_START', { - projectId, - index, - total, - }); - - set({ - pullState: { - active: true, - step: 'downloading', - progress: Math.min(95, Math.round((index / total) * 90)), - projectId, - mode: 'bulk', - current: index, - total, - }, - }); - - if (!remote?.fileId) { - console.warn('[sync] Missing remote file for pull', projectId); - continue; - } - - try { - const result = await pullProjectFromFile({ - projectId, - fileId: remote.fileId, - revision: remote.revision, - passphrase, - provider: cloudProviderId, - }); - console.log('[sync] Pulled project', result); - traceSyncEvent('PULL_PROJECT_DONE', { - projectId, - index, - total, - elapsedMs: Date.now() - pullProjectStartedAt, - }); - } catch (error) { - console.error('[sync] Pull failed', projectId, error); - traceSyncEvent('PULL_PROJECT_ERROR', { - projectId, - index, - total, - elapsedMs: Date.now() - pullProjectStartedAt, - message: String(error?.message || error), - }); - } - } - - const elapsed = Date.now() - startedAt; - if (elapsed < 2000) { - await delay(2000 - elapsed); - } - - set({ - status: 'connected', - lastSyncedAt: new Date().toISOString(), - pullState: resetPullState, - }); - console.log('[sync] Pull-only sync complete'); - traceSyncEvent('COMPLETE', { - mode: 'pull', - elapsedMs: Date.now() - syncStartedAt, - }); - return; - } - - traceSyncEvent('FULL_SYNC_ENGINE_START', { provider: cloudProviderId }); - await syncAllProjectsEngine({ - mode: 'full', - passphrase, - passphrases: providerPassphrases, - provider: cloudProviderId, - providers: engineProviders, - onProgress: (info) => { - if (info?.phase === 'start') { - total = info.total || 0; - traceSyncEvent('FULL_SYNC_ENGINE_PROGRESS', { - phase: 'start', - total: info.total || 0, - remoteCount: info.remoteCount || 0, - localCount: info.localCount || 0, - }); - if (info.remoteCount > 0) { - shouldShow = true; - set({ - pullState: { - active: true, - step: 'checking', - progress: 5, - projectId: null, - mode: 'bulk', - current: 0, - total, - }, - }); - } - return; - } - - if (!shouldShow || !total) return; - const progress = Math.min(100, Math.round((info.index / total) * 100)); - traceSyncEvent('FULL_SYNC_ENGINE_PROGRESS', { - phase: info.phase || 'sync', - index: info.index || 0, - total, - projectId: info.projectId || null, - progress, - }); - set({ - pullState: { - active: true, - step: info.phase === 'pull' ? 'downloading' : 'applying', - progress, - projectId: info.projectId || null, - mode: 'bulk', - current: info.index || 0, - total, - }, - }); - }, - }); - traceSyncEvent('FULL_SYNC_ENGINE_DONE', { - elapsedMs: Date.now() - syncStartedAt, - }); - set({ status: 'connected', lastSyncedAt: new Date().toISOString() }); - set({ pullState: resetPullState }); - traceSyncEvent('COMPLETE', { - mode, - elapsedMs: Date.now() - syncStartedAt, - }); - } catch (error) { - console.error('Sync all failed:', error); - traceSyncEvent('ERROR', { - mode, - elapsedMs: Date.now() - syncStartedAt, - message: String(error?.message || error), - }); - const message = (error?.message || '').toLowerCase(); - if (message.includes('tokenexpired')) { - set({ status: 'error', error: 'tokenExpired' }); - } else { - set({ status: 'error', error: 'syncFailed' }); - } - set({ pullState: resetPullState }); - } - }, - - schedulePush: (projectId) => { - const state = get(); - const { status, provider, syncMode } = state; - const cloudProviderId = resolveCloudProviderId(state); - if (!provider) return; - if (!canRunSyncForStatus(cloudProviderId, status)) return; - if (!cloudProviderId) return; - if (!projectId) return; - if (syncMode === 'list') { - console.log('[sync] Skip auto-push (list mode)'); - return; - } - - const existing = pushQueue.get(projectId); - if (existing) { - clearTimeout(existing); - } - - const timer = setTimeout(() => { - console.log('[sync] Auto-push project', projectId); - pushQueue.delete(projectId); - get().pushProject(projectId); - }, 1500); - pushQueue.set(projectId, timer); - }, - - checkRemoteDiff: async (projectId) => { - const currentState = get(); - const { status, provider } = currentState; - const cloudProviderId = resolveCloudProviderId(currentState); - if (status !== 'connected' || !provider) return false; - if (!cloudProviderId) return false; - - try { - return await checkRemoteDiffEngine(projectId, { provider: cloudProviderId }); - } catch (error) { - console.error('Remote diff check failed:', error); - const message = (error?.message || '').toLowerCase(); - if (message.includes('tokenexpired')) { - set({ status: 'error', error: 'tokenExpired' }); - } else { - set({ error: 'syncFailed' }); - } - return false; - } - }, - - startPull: async (projectId, options = {}) => { - const { simulate = false } = options; - const currentState = get(); - const { syncMode, provider } = currentState; - const cloudProviderId = resolveCloudProviderId(currentState); - const passphrase = resolveSyncPassphrase(currentState, provider); - const providerConfig = getSyncProviderConfig(provider); - const requiresPassphrase = providerConfig?.requiresPassphrase !== false; - if (syncMode !== 'full') { - console.log('[sync] Skip pull (non-full mode)'); - return; - } - if (!cloudProviderId) return; - if (requiresPassphrase && (!passphrase || !passphrase.trim())) { - set({ error: 'passphraseRequired', status: 'error' }); - return; - } - - set({ - status: 'syncing', - error: null, - pullState: { - active: true, - step: 'checking', - progress: 10, - projectId, - mode: 'project', - current: 0, - total: 0, - }, - }); - - let didError = false; - - if (simulate) { - const steps = [ - { step: 'checking', progress: 20 }, - { step: 'downloading', progress: 45 }, - { step: 'decrypting', progress: 70 }, - { step: 'applying', progress: 95 }, - ]; - - for (const item of steps) { - await delay(450); - set({ - pullState: { - active: true, - step: item.step, - progress: item.progress, - projectId, - }, - }); - } - - await delay(300); - } - - try { - await pullProject({ - projectId, - passphrase, - provider: cloudProviderId, - onProgress: (step, progress) => - set({ - pullState: { - active: true, - step, - progress, - projectId, - mode: 'project', - current: 0, - total: 0, - }, - }), - }); - } catch (error) { - console.error('Pull failed:', error); - didError = true; - const message = (error?.message || '').toLowerCase(); - if (message.includes('tokenexpired')) { - set({ status: 'error', error: 'tokenExpired' }); - } else { - set({ status: 'error', error: 'syncFailed' }); - } - } - - if (!didError) { - set({ - status: 'connected', - lastSyncedAt: new Date().toISOString(), - pullState: createResetPullState('project'), - }); - } else { - set({ - pullState: createResetPullState('project'), - }); - } - }, - - pushProject: async (projectId) => { - const currentState = get(); - const { syncMode, provider } = currentState; - const cloudProviderId = resolveCloudProviderId(currentState); - const passphrase = resolveSyncPassphrase(currentState, provider); - const providerConfig = getSyncProviderConfig(provider); - const requiresPassphrase = providerConfig?.requiresPassphrase !== false; - if (syncMode === 'list') { - console.log('[sync] Skip push (list mode)'); - return; - } - if (!cloudProviderId) return; - if (requiresPassphrase && (!passphrase || !passphrase.trim())) { - set({ error: 'passphraseRequired' }); - return; - } - try { - await pushProjectToRemote({ projectId, passphrase, provider: cloudProviderId }); - } catch (error) { - console.error('Push failed:', error); - set({ error: 'syncFailed' }); - } - }, + // Deletion intent is committed with authoring data by the repository. + // These compatibility hooks only wake the durable revision worker. + processPendingTombstones: async () => get().syncAllProjects({ mode: 'push' }), + scheduleProjectDeletion: (projectId) => get().schedulePush(projectId), + scheduleDialogueDeletion: (_dialogueId, projectId) => get().schedulePush(projectId), + + conflicts: [], + syncResults: null, + queueState: null, + refreshConflicts: async () => { + const context = await getRepositoryContext(); + const conflicts = await context.db.syncConflicts.where('status').equals('unresolved').toArray(); + context.assertCurrent(); set({ conflicts }); return conflicts; + }, + resolveConflict: async (id, choice, revisionId) => { + const context = await getRepositoryContext(); + const result = await resolveRevisionConflict(id, choice, { context, revisionId }); + context.assertCurrent(); await get().refreshConflicts(); + get().schedulePush(result.projectId); + if (result.copiedProjectId) get().schedulePush(result.copiedProjectId); + return result; + }, + syncAllProjects: async (options = {}) => { + const state = get(), provider = resolveCloudProviderId(state); + const mode = options.mode || state.syncMode || 'full'; + if (!provider || !['connected', 'syncing', 'error'].includes(state.status)) return; + const passphrase = resolveSyncPassphrase(state, provider); + if (getSyncProviderConfig(provider)?.requiresPassphrase !== false && !passphrase?.trim()) { + set({ error: 'passphraseRequired' }); return; + } + const context = await getRepositoryContext(); + set({ status: 'syncing', error: null }); + try { + const result = await syncRevisions({ ...options, provider, passphrase, context, mode }); + context.assertCurrent(); + set({ status: 'connected', error: result.failures.length ? 'syncFailed' : null, syncResults: result, queueState: result.queue, + ...(mode !== 'list' && !result.failures.length ? { lastSyncedAt: new Date().toISOString() } : {}), pullState: createResetPullState() }); + await get().refreshConflicts(); + traceSyncEvent('COMPLETE', { provider, mode, failures: result.failures.length, conflicts: result.conflicts.length, queue: result.queue }); + return result; + } catch (failure) { + if (!context.signal.aborted) set({ status: 'error', error: 'syncFailed', pullState: createResetPullState() }); + traceSyncEvent('FAILED', { provider, mode, code: failure.code || 'SYNC_FAILED' }); + return { failures: [{ code: failure.code || 'SYNC_FAILED', message: failure.message }] }; + } + }, + schedulePush: (projectId) => { + if (!projectId) return; + const profileId = getActiveProfileId(), key = `${profileId}:${projectId}`; + clearTimeout(pushQueue.get(key)); + pushQueue.set(key, setTimeout(() => { + pushQueue.delete(key); + if (getActiveProfileId() !== profileId) return; + const state = get(); + if (['full', 'push'].includes(state.syncMode)) void state.syncAllProjects({ mode: state.syncMode, projectId }); + }, 1500)); + }, + checkRemoteDiff: async (projectId) => { + const result = await get().syncAllProjects({ mode: 'list', projectId }); + return !!result?.comparisons?.some(item => item.projectId === projectId && item.remoteRevisionIds.some(id => id !== item.localRevisionId)); + }, + startPull: async (projectId) => get().syncAllProjects({ mode: 'pull', projectId }), + pushProject: async (projectId) => { + if (!['full', 'push'].includes(get().syncMode)) return; + return get().syncAllProjects({ mode: 'push', projectId }); + }, }), { name: SYNC_STORAGE_KEY, From a1537c7d931dca11eba7a098c92a7ce67001be02 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:23 +0200 Subject: [PATCH 13/22] U: Track achievements and activity per profile Persist earned achievements with retry until acknowledged and keep playtime state per profile. --- src/lib/achievements/achievementTracker.js | 123 ++++++++++++++------- 1 file changed, 81 insertions(+), 42 deletions(-) diff --git a/src/lib/achievements/achievementTracker.js b/src/lib/achievements/achievementTracker.js index 10805af..e817923 100644 --- a/src/lib/achievements/achievementTracker.js +++ b/src/lib/achievements/achievementTracker.js @@ -3,6 +3,8 @@ import { useSteamStore } from '@/stores/steamStore'; import { STEAM_ACHIEVEMENT_IDS } from '@/config/steamAchievements'; import { readProfileScopedItem, + getActiveProfileId, getProfileGeneration, + buildProfileScopedKey, subscribeProfileChanges, writeProfileScopedItem, } from '@/lib/profile/activeProfile'; @@ -33,29 +35,49 @@ function saveAchievementState(nextState) { writeLocalStorage(ACHIEVEMENT_STATE_KEY, JSON.stringify(nextState || {})); } -function isAchievementUnlocked(achievementId) { - const state = loadAchievementState(); - return Boolean(state[achievementId]); +const pendingRetries = new Map(); + +export async function retryPendingAchievements() { + const profile = getActiveProfileId(), generation = getProfileGeneration(); + const status = useSteamStore.getState().status; + if (!status?.available || (status.steamId && profile !== `steam-${status.steamId}`)) return false; + if (pendingRetries.has(profile)) return pendingRetries.get(profile); + const eligible = ([id, entry]) => entry && !entry.acknowledgedAt && Object.values(STEAM_ACHIEVEMENT_IDS).includes(id); + if (!Object.entries(loadAchievementState()).some(eligible)) return false; + const task = (async () => { + const attempted = new Set(); + while (profile === getActiveProfileId() && generation === getProfileGeneration()) { + const next = Object.entries(loadAchievementState()).find((entry) => eligible(entry) && !attempted.has(entry[0])); + if (!next) break; + const [id, entry] = next; + attempted.add(id); + try { + const result = await useSteamStore.getState().unlockAchievement(id); + if (profile !== getActiveProfileId() || generation !== getProfileGeneration()) break; + if (result?.ok) { + const state = loadAchievementState(); + state[id] = { ...entry, status: 'acknowledged', acknowledgedAt: new Date().toISOString() }; + saveAchievementState(state); + } + } catch { /* Earned state remains pending for the next availability/timer retry. */ } + } + return true; + })(); + pendingRetries.set(profile, task); + try { return await task; } finally { pendingRetries.delete(profile); } } async function unlockAchievement(achievementId) { - const id = String(achievementId || '').trim(); - if (!id) return false; - if (isAchievementUnlocked(id)) return false; - - const state = loadAchievementState(); - state[id] = { - unlockedAt: new Date().toISOString(), - }; - saveAchievementState(state); - - try { - await useSteamStore.getState().unlockAchievement(id); - } catch (error) { - // Keep local unlock state even when Steam runtime is unavailable. - } - - return true; + const id = String(achievementId || '').trim(); + if (!id) return false; + const state = loadAchievementState(); + const newlyEarned = !state[id]; + if (newlyEarned) { + state[id] = { earnedAt: new Date().toISOString(), status: 'pending' }; + saveAchievementState(state); + } + await retryPendingAchievements(); + return newlyEarned; } function isExampleProject(project) { @@ -118,34 +140,51 @@ export async function trackFirstConditionCreated(projectId) { ); } -export function markUserActivity() { - writeLocalStorage(LAST_ACTIVITY_TS_KEY, String(Date.now())); +const ACTIVITY_STATE_KEY = 'mountea-activity-v2'; +const activityByProfile = new Map(); +function activityState() { + const profile = getActiveProfileId(); + if (!activityByProfile.has(profile)) { + let saved = {}; + try { saved = JSON.parse(readLocalStorage(ACTIVITY_STATE_KEY, '{}')); } catch { /* Recover invalid preferences. */ } + const minutes = Number(saved?.minutes ?? readLocalStorage(PLAYTIME_MINUTES_KEY, '0')); + const lastActivity = Number(saved?.lastActivity ?? readLocalStorage(LAST_ACTIVITY_TS_KEY, '0')); + activityByProfile.set(profile, { minutes: Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0, lastActivity: Number.isFinite(lastActivity) ? lastActivity : 0, lastTick: Date.now(), lastFlush: 0, dirty: false }); + } + return activityByProfile.get(profile); } -export function getTrackedPlaytimeMinutes() { - const raw = readLocalStorage(PLAYTIME_MINUTES_KEY, '0'); - const value = Number(raw); - if (!Number.isFinite(value) || value < 0) return 0; - return Math.floor(value); +export function markUserActivity() { + const state = activityState(); + state.lastActivity = Date.now(); + state.dirty = true; } -function setTrackedPlaytimeMinutes(minutes) { - writeLocalStorage(PLAYTIME_MINUTES_KEY, String(minutes)); +export function flushActivity({ force = false, profile = getActiveProfileId() } = {}) { + const state = activityByProfile.get(profile), now = Date.now(); + if (!state) return false; + if (!state.dirty || (!force && now - state.lastFlush < 60_000)) return false; + window.localStorage.setItem(buildProfileScopedKey(ACTIVITY_STATE_KEY, profile), JSON.stringify({ minutes: state.minutes, lastActivity: state.lastActivity })); + state.lastFlush = now; + state.dirty = false; + return true; } -export async function trackActiveMinute() { - const lastActivityRaw = readLocalStorage(LAST_ACTIVITY_TS_KEY, '0'); - const lastActivityTs = Number(lastActivityRaw); - if (!Number.isFinite(lastActivityTs)) return false; - if (Date.now() - lastActivityTs > ACTIVE_WINDOW_MS) return false; - if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return false; - - const minutes = getTrackedPlaytimeMinutes() + 1; - setTrackedPlaytimeMinutes(minutes); +subscribeProfileChanges(() => { + for (const profile of activityByProfile.keys()) flushActivity({ force: true, profile }); + void retryPendingAchievements(); +}); - if (minutes >= 600) { - return await unlockAchievement(STEAM_ACHIEVEMENT_IDS.POWER_USER_10H); - } +export function getTrackedPlaytimeMinutes() { return activityState().minutes; } - return false; +export async function trackActiveMinute() { + const state = activityState(), now = Date.now(); + if (now - state.lastTick < 60_000) return false; + state.lastTick = now; + const eligible = now - state.lastActivity <= ACTIVE_WINDOW_MS && (typeof document === 'undefined' || document.visibilityState === 'visible'); + if (eligible) { state.minutes += 1; state.dirty = true; } + flushActivity(); + await retryPendingAchievements(); + if (eligible && state.minutes >= 600) return await unlockAchievement(STEAM_ACHIEVEMENT_IDS.POWER_USER_10H); + return false; } From 1a6df6ac6c6aed394c33f6374aef4a1dff41b623 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:23 +0200 Subject: [PATCH 14/22] A: Add editor draft recovery Add draft persistence helper and hook; use them in the dialogue editor route. --- src/hooks/useEditorDraft.js | 49 ++++ src/lib/editorDraft.js | 71 ++++++ .../$projectId/dialogue/$dialogueId/index.jsx | 223 +++++++----------- 3 files changed, 203 insertions(+), 140 deletions(-) create mode 100644 src/hooks/useEditorDraft.js create mode 100644 src/lib/editorDraft.js diff --git a/src/hooks/useEditorDraft.js b/src/hooks/useEditorDraft.js new file mode 100644 index 0000000..861afdd --- /dev/null +++ b/src/hooks/useEditorDraft.js @@ -0,0 +1,49 @@ +import { useCallback, useRef, useState } from 'react'; +import { applyNodeChanges, applyEdgeChanges } from '@xyflow/react'; +import { cloneGraphDraft, createEditorDraft, editorDraftReducer } from '@/lib/editorDraft'; + +export function useEditorDraft(initialNodes, initialEdges) { + const [state, render] = useState(() => createEditorDraft(initialNodes, initialEdges)); + const current = useRef(state); + // Resolve event updates once against the current draft, never inside a React + // functional updater (which React may defer or replay in StrictMode). + const dispatch = useCallback((action) => { + current.current = editorDraftReducer(current.current, action); + render(current.current); + return current.current; + }, []); + const setNodes = useCallback((value) => { + const next = typeof value === 'function' ? value(current.current.nodes) : value; + dispatch({ type: 'nodes', value: next }); + }, [dispatch]); + const setEdges = useCallback((value) => { + const next = typeof value === 'function' ? value(current.current.edges) : value; + dispatch({ type: 'edges', value: next }); + }, [dispatch]); + const markUnsaved = useCallback(() => dispatch({ type: 'dirty' }), [dispatch]); + const onNodesChange = useCallback((changes) => { + setNodes((nodes) => applyNodeChanges(changes, nodes)); + if (changes.some((change) => ['position', 'remove', 'add', 'replace'].includes(change.type))) markUnsaved(); + if (changes.some((change) => change.type === 'position' && change.dragging === false)) { + dispatch({ type: 'checkpoint', nodes: current.current.nodes, edges: current.current.edges }); + } + }, [setNodes, markUnsaved, dispatch]); + const onEdgesChange = useCallback((changes) => { + setEdges((edges) => applyEdgeChanges(changes, edges)); + if (changes.some((change) => change.type !== 'select')) markUnsaved(); + }, [setEdges, markUnsaved]); + const saveToHistory = useCallback((nodes, edges) => dispatch({ type: 'checkpoint', nodes, edges }), [dispatch]); + const resetDraft = useCallback((nodes, edges) => dispatch({ type: 'reset', nodes, edges }), [dispatch]); + const updateNodeData = useCallback((id, data) => { + const revision = current.current.revision; + return dispatch({ type: 'nodeData', id, data }).revision !== revision; + }, [dispatch]); + const captureSave = useCallback(() => ({ ...cloneGraphDraft(current.current.nodes, current.current.edges), revision: current.current.revision, generation: current.current.generation }), []); + const acknowledgeSave = useCallback((ticket) => { + const next = dispatch({ type: 'saved', ticket }); + return ticket.generation === next.generation && next.revision === next.savedRevision; + }, [dispatch]); + const undo = useCallback(() => dispatch({ type: 'undo' }), [dispatch]); + const redo = useCallback(() => dispatch({ type: 'redo' }), [dispatch]); + return { ...state, setNodes, setEdges, onNodesChange, onEdgesChange, markUnsaved, saveToHistory, resetDraft, updateNodeData, captureSave, acknowledgeSave, undo, redo, hasUnsavedChanges: state.revision !== state.savedRevision }; +} diff --git a/src/lib/editorDraft.js b/src/lib/editorDraft.js new file mode 100644 index 0000000..378b986 --- /dev/null +++ b/src/lib/editorDraft.js @@ -0,0 +1,71 @@ +// Durable graph drafts retain binary attachments and unknown authored fields. +// Runtime callbacks, object URLs and React Flow interaction state are never history. +function cleanValue(value) { + if (typeof value === 'function' || typeof value === 'symbol') return undefined; + if (!value || typeof value !== 'object' || value instanceof Blob || value instanceof Date || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value; + if (Array.isArray(value)) return value.map(cleanValue); + return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => { + if ((key === 'url' || key === 'src') && typeof item === 'string' && item.startsWith('blob:')) return []; + const cleaned = cleanValue(item); + return cleaned === undefined ? [] : [[key, cleaned]]; + })); +} + +export function cloneGraphDraft(nodes = [], edges = []) { + const cleanRecord = (record) => { + const result = cleanValue(record); + for (const key of ['selected', 'dragging', 'resizing', 'measured', 'positionAbsolute']) delete result[key]; + return result; + }; + const regularNodes = nodes.filter((node) => node.type !== 'placeholderNode'); + const ids = new Set(regularNodes.map((node) => node.id)); + return structuredClone({ + nodes: regularNodes.map(cleanRecord), + edges: edges.filter((edge) => !edge.data?.isPlaceholder && ids.has(edge.source) && ids.has(edge.target)).map(cleanRecord), + }); +} + +export function createEditorDraft(nodes, edges, generation = 0) { + return { nodes, edges, history: [cloneGraphDraft(nodes, edges)], historyIndex: 0, revision: 0, savedRevision: 0, generation }; +} + +export function editorDraftReducer(state, action) { + switch (action.type) { + case 'nodes': return { ...state, nodes: action.value }; + case 'edges': return { ...state, edges: action.value }; + case 'dirty': return { ...state, history: state.history.slice(0, state.historyIndex + 1), revision: state.revision + 1 }; + case 'reset': return createEditorDraft(action.nodes, action.edges, state.generation + 1); + case 'saved': return action.ticket.generation === state.generation + ? { ...state, savedRevision: Math.max(state.savedRevision, action.ticket.revision) } : state; + case 'checkpoint': { + const history = [...state.history.slice(0, state.historyIndex + 1), cloneGraphDraft(action.nodes, action.edges)].slice(-50); + return { ...state, history, historyIndex: history.length - 1 }; + } + case 'undo': + case 'redo': { + const index = state.historyIndex + (action.type === 'undo' ? -1 : 1); + if (index < 0 || index >= state.history.length) return state; + const graph = cloneGraphDraft(state.history[index].nodes, state.history[index].edges); + return { ...state, ...graph, historyIndex: index, revision: state.revision + 1 }; + } + case 'nodeData': { + const node = state.nodes.find((candidate) => candidate.id === action.id); + if (!node || !Object.entries(action.data).some(([key, value]) => node.data?.[key] !== value)) return state; + return { ...state, history: state.history.slice(0, state.historyIndex + 1), revision: state.revision + 1, nodes: state.nodes.map((candidate) => candidate === node ? { ...node, data: { ...node.data, ...action.data } } : candidate) }; + } + default: return state; + } +} + +export function createDecoratorInstance(definition) { + return { id: definition.id, name: definition.name, values: Object.fromEntries((definition.properties || []).map((property) => [property.name, property.defaultValue ?? ''])) }; +} + +export function createEditorSaveQueue() { + let tail = Promise.resolve(); + return (operation) => { + const result = tail.then(operation); + tail = result.catch(() => {}); + return result; + }; +} diff --git a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx index c949434..895a64f 100644 --- a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx +++ b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx @@ -1,3 +1,6 @@ +import { toast } from '@/components/ui/toaster'; +import { errorToast } from '@/lib/errorPresentation'; +import { assertGraphNodesRemovable, categoryAncestors } from '@/lib/domainIntegrity'; import { createFileRoute, useBlocker } from '@tanstack/react-router'; import { useEffect, useState, useCallback, useRef, useLayoutEffect, useMemo, createContext, useContext } from 'react'; import { createPortal } from 'react-dom'; @@ -8,14 +11,14 @@ import { Background, MiniMap, addEdge, - useNodesState, - useEdgesState, MarkerType, Panel, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import dagre from 'dagre'; import { ZoomSlider } from '@/components/dialogue/ZoomSlider'; +import { useEditorDraft } from '@/hooks/useEditorDraft'; +import { createDecoratorInstance, createEditorSaveQueue } from '@/lib/editorDraft'; import { ArrowLeft, MessageCircle, @@ -356,7 +359,7 @@ function DialogueEditorPage() { () => normalizeProjectLocalizationConfig(project?.localization || {}), [project?.localization] ); - const localizationEnabled = Boolean(projectLocalization.enabled); + const localizationEnabled = true; const contentLocale = useMemo(() => { const savedLocale = normalizeLocaleTag(contentLocaleByProject?.[projectId], ''); if ( @@ -376,8 +379,12 @@ function DialogueEditorPage() { [loadDialogueGraphForPreviewFromStore, contentLocale] ); - const [nodes, setNodes, onNodesChangeBase] = useNodesState(getInitialNodes(t)); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const { + nodes, edges, setNodes, setEdges, onNodesChange: onNodesChangeBase, onEdgesChange, + hasUnsavedChanges, markUnsaved, saveToHistory, resetDraft, + updateNodeData: updateDraftNodeData, captureSave, acknowledgeSave, + undo, redo, + } = useEditorDraft(getInitialNodes(t), initialEdges); // Prevent deletion of Start Node const onNodesChange = useCallback((changes) => { @@ -387,8 +394,9 @@ function DialogueEditorPage() { } return true; }); + try { assertGraphNodesRemovable(nodes, new Set(filteredChanges.filter((change) => change.type === 'remove').map((change) => change.id))); } catch (error) { toast(errorToast(error, 'delete')); return; } onNodesChangeBase(filteredChanges); - }, [onNodesChangeBase]); + }, [nodes, onNodesChangeBase]); const [selectedNodeId, setSelectedNodeId] = useState(null); const selectedNode = useMemo( () => nodes.find((node) => node.id === selectedNodeId) || null, @@ -396,15 +404,13 @@ function DialogueEditorPage() { ); const [selectedEdge, setSelectedEdge] = useState(null); const [, setIsSaving] = useState(false); + const saveInFlightRef = useRef(false); + const saveQueueRef = useRef(null); + if (!saveQueueRef.current) saveQueueRef.current = createEditorSaveQueue(); const [saveStatus, setSaveStatus] = useState('saved'); const [lastSaved, setLastSaved] = useState(null); const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 }); const [reactFlowInstance, setReactFlowInstance] = useState(null); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const markUnsaved = useCallback(() => { - setHasUnsavedChanges(true); - setSaveStatus((prev) => (prev === 'saving' ? prev : 'unsaved')); - }, []); // Onboarding tour const { runTour, finishTour, resetTour } = useOnboarding('dialogue-editor'); @@ -439,7 +445,8 @@ function DialogueEditorPage() { }; updateDeviceType(); window.addEventListener('resize', updateDeviceType); - return () => window.removeEventListener('resize', updateDeviceType); + window.addEventListener('device-override', updateDeviceType); + return () => { window.removeEventListener('resize', updateDeviceType); window.removeEventListener('device-override', updateDeviceType); }; }, []); useLayoutEffect(() => { @@ -507,12 +514,6 @@ function DialogueEditorPage() { }; }, [deviceType, isMobilePanelOpen]); - // History for undo/redo - const [history, setHistory] = useState([ - { nodes: getInitialNodes(t), edges: initialEdges }, - ]); - const [historyIndex, setHistoryIndex] = useState(0); - // Load data useEffect(() => { loadProjects(); @@ -570,10 +571,7 @@ function DialogueEditorPage() { const { nodes: loadedNodes, edges: loadedEdges, viewport: loadedViewport } = result; if (loadedNodes.length > 0 || loadedEdges.length > 0) { - setNodes(loadedNodes); - setEdges(loadedEdges); - setHistory([{ nodes: loadedNodes, edges: loadedEdges }]); - setHistoryIndex(0); + resetDraft(loadedNodes, loadedEdges); } // Restore viewport if saved and ReactFlow instance is available @@ -592,7 +590,7 @@ function DialogueEditorPage() { } }; loadGraph(); - }, [dialogueId, loadDialogueGraph, setNodes, setEdges, reactFlowInstance, contentLocale]); + }, [dialogueId, loadDialogueGraph, resetDraft, reactFlowInstance, contentLocale]); const isMobileGraphLoading = deviceType !== 'desktop' && @@ -758,25 +756,6 @@ function DialogueEditorPage() { ]); - // Save to history for undo/redo - const saveToHistory = useCallback( - (newNodes, newEdges) => { - setHistory((prev) => { - const newHistory = prev.slice(0, historyIndex + 1); - newHistory.push({ nodes: newNodes, edges: newEdges }); - // Keep only last 50 states to avoid memory issues - if (newHistory.length > 50) { - newHistory.shift(); - setHistoryIndex(newHistory.length - 1); - return newHistory; - } - setHistoryIndex(newHistory.length - 1); - return newHistory; - }); - }, - [historyIndex] - ); - // Save to history when selected node changes (user clicked away after editing) const prevSelectedNodeRef = useRef(selectedNodeId); useEffect(() => { @@ -794,7 +773,7 @@ function DialogueEditorPage() { }, [selectedNodeId, nodes, edges, saveToHistory]); // Warn before leaving with unsaved changes - const shouldWarnOnLeave = hasUnsavedChanges && saveStatus !== 'saved'; + const shouldWarnOnLeave = hasUnsavedChanges; useEffect(() => { const handleBeforeUnload = (e) => { @@ -879,22 +858,13 @@ function DialogueEditorPage() { case 'select': if (field.options === 'participants') { const groups = new Map(); - const getCategoryPath = (categoryName) => { - const category = projectCategories.find((c) => c.name === categoryName); - if (!category) return categoryName; - const path = []; - let current = category; - while (current) { - path.unshift(current.name); - current = projectCategories.find( - (c) => c.id === current.parentCategoryId - ); - } - return path.join(' > '); + const getCategoryPath = (participant) => { + try { return categoryAncestors(participant.categoryId, projectCategories).reverse().map((item) => item.name).join(' > ') || participant.category; } + catch { return participant.category || ''; } }; participants.forEach((participant) => { const categoryName = participant.category || t('categories.title'); - const categoryPath = getCategoryPath(categoryName); + const categoryPath = getCategoryPath(participant); const root = categoryPath.split(' > ')[0] || categoryName; if (!groups.has(root)) { groups.set(root, []); @@ -917,9 +887,9 @@ function DialogueEditorPage() { - updateNodeData(selectedNode.id, { [field.id]: e.target.value }) + updateNodeData(selectedNode.id, { participantId: e.target.value, [field.id]: participants.find((participant) => participant.id === e.target.value)?.name || '' }) } > {group.options.map((option) => ( - ))} @@ -1206,6 +1176,7 @@ function DialogueEditorPage() { const deleteNodeById = useCallback((nodeId) => { if (!nodeId || nodeId === START_NODE_ID) return; + try { assertGraphNodesRemovable(nodes, new Set([nodeId])); } catch (error) { toast(errorToast(error, 'delete')); return; } setNodes((nds) => { const placeholderIdsToRemove = nds @@ -1237,7 +1208,7 @@ function DialogueEditorPage() { }); setSelectedNodeId((current) => (current === nodeId ? null : current)); - }, [setNodes, setEdges, saveToHistory, markUnsaved]); + }, [nodes, setNodes, setEdges, saveToHistory, markUnsaved]); const deleteEdgeById = useCallback((edgeId) => { if (!edgeId) return; @@ -1274,6 +1245,8 @@ function DialogueEditorPage() { }); } + try { assertGraphNodesRemovable(nodes, nodeIdsToRemove); } catch (error) { toast(errorToast(error, 'delete')); return; } + setNodes((nds) => { const placeholderIdsToRemove = nds .filter( @@ -1301,7 +1274,7 @@ function DialogueEditorPage() { }); setSelectedNodeId(null); - }, [selectedNode, edges, setNodes, setEdges, saveToHistory, markUnsaved]); + }, [selectedNode, nodes, edges, setNodes, setEdges, saveToHistory, markUnsaved]); // Checks if edge is the only incoming connection to its target, then either // opens cascade-delete dialog or deletes the edge directly. @@ -1567,37 +1540,41 @@ function DialogueEditorPage() { // Handle save const handleSave = useCallback(async () => { + if (saveInFlightRef.current) return; + saveInFlightRef.current = true; setIsSaving(true); setSaveStatus('saving'); try { + const ticket = captureSave(); clearToasts(); // Filter out placeholder nodes and their edges before saving - const regularNodes = nodes.filter((n) => n.type !== 'placeholderNode'); - const regularEdges = edges.filter((e) => !e.data?.isPlaceholder); + const regularNodes = ticket.nodes; + const regularEdges = ticket.edges; const missingRequiredNodes = getMissingRequiredNodes(); logMissingRequiredNodes(); showValidationToasts(missingRequiredNodes); - await saveDialogueGraph(dialogueId, regularNodes, regularEdges, viewport, { + await saveQueueRef.current(() => saveDialogueGraph(dialogueId, regularNodes, regularEdges, viewport, { activeLocale: contentLocale, - }); + })); const now = new Date(); setLastSaved(now); - setSaveStatus('saved'); - setHasUnsavedChanges(false); - hasPendingNodeDataEditsRef.current = false; + const fullySaved = acknowledgeSave(ticket); + setSaveStatus(fullySaved ? 'saved' : 'unsaved'); + if (fullySaved) hasPendingNodeDataEditsRef.current = false; celebrateSuccess(); } catch (error) { console.error('Failed to save dialogue:', error); setSaveStatus('error'); setTimeout(() => setSaveStatus('unsaved'), 3000); } finally { + saveInFlightRef.current = false; setIsSaving(false); } }, [ - edges, - nodes, + captureSave, + acknowledgeSave, viewport, dialogueId, saveDialogueGraph, @@ -1607,37 +1584,17 @@ function DialogueEditorPage() { showValidationToasts, setIsSaving, setSaveStatus, - setHasUnsavedChanges, setLastSaved, ]); // Update node data (without saving to history on every keystroke) const updateNodeData = useCallback( (nodeId, newData) => { - let didChange = false; - setNodes((nds) => - nds.map((node) => - node.id === nodeId - ? (() => { - const currentData = node.data || {}; - const hasValueChange = Object.entries(newData || {}).some( - ([key, value]) => currentData[key] !== value - ); - if (!hasValueChange) { - return node; - } - didChange = true; - return { ...node, data: { ...currentData, ...newData } }; - })() - : node - ) - ); - if (didChange) { + if (updateDraftNodeData(nodeId, newData || {})) { hasPendingNodeDataEditsRef.current = true; - markUnsaved(); } }, - [setNodes, markUnsaved] + [updateDraftNodeData] ); // Add decorator to node @@ -1647,18 +1604,7 @@ function DialogueEditorPage() { const updatedNodes = nds.map((node) => { if (node.id === nodeId) { // Create decorator instance with default values - const decoratorInstance = { - id: decoratorDef.id, - name: decoratorDef.name, - values: {}, - }; - - // Populate default values from decorator properties - if (decoratorDef.properties && decoratorDef.properties.length > 0) { - decoratorDef.properties.forEach((prop) => { - decoratorInstance.values[prop.name] = prop.defaultValue || ''; - }); - } + const decoratorInstance = createDecoratorInstance(decoratorDef); return { ...node, @@ -2130,25 +2076,18 @@ function DialogueEditorPage() { // Undo const handleUndo = useCallback(() => { - if (historyIndex > 0) { - const newIndex = historyIndex - 1; - const { nodes: prevNodes, edges: prevEdges } = history[newIndex]; - setNodes(prevNodes); - setEdges(prevEdges); - setHistoryIndex(newIndex); + if (hasPendingNodeDataEditsRef.current) { + const draft = captureSave(); + saveToHistory(draft.nodes, draft.edges); + hasPendingNodeDataEditsRef.current = false; } - }, [historyIndex, history, setNodes, setEdges]); + undo(); + }, [undo, captureSave, saveToHistory]); // Redo const handleRedo = useCallback(() => { - if (historyIndex < history.length - 1) { - const newIndex = historyIndex + 1; - const { nodes: nextNodes, edges: nextEdges } = history[newIndex]; - setNodes(nextNodes); - setEdges(nextEdges); - setHistoryIndex(newIndex); - } - }, [historyIndex, history, setNodes, setEdges]); + redo(); + }, [redo]); // Handle keyboard events useEffect(() => { @@ -2225,7 +2164,8 @@ function DialogueEditorPage() { // Handle viewport change const onMove = useCallback((event, newViewport) => { setViewport(newViewport); - }, []); + if (event) markUnsaved(); + }, [markUnsaved]); const getMinimapColor = useCallback((node) => { return getNodeDefinition(node.type)?.minimapColor || '#6b7280'; @@ -2306,20 +2246,20 @@ function DialogueEditorPage() { // Handle export const handleExport = useCallback(async () => { try { - // Filter out placeholder nodes and their edges before saving - const regularNodes = nodes.filter((n) => n.type !== 'placeholderNode'); - const regularEdges = edges.filter((e) => !e.data?.isPlaceholder); - - // Save first to ensure we export the latest version - await saveDialogueGraph(dialogueId, regularNodes, regularEdges, viewport, { - activeLocale: contentLocale, + const ticket = captureSave(); + // Save/export share the same commit queue: an older pending save must + // never overwrite this draft or interleave between its save and export. + await saveQueueRef.current(async () => { + await saveDialogueGraph(dialogueId, ticket.nodes, ticket.edges, viewport, { activeLocale: contentLocale }); + const fullySaved = acknowledgeSave(ticket); + setSaveStatus(fullySaved ? 'saved' : 'unsaved'); + setLastSaved(new Date()); + await exportDialogue(dialogueId); }); - // Then export - await exportDialogue(dialogueId); } catch (error) { console.error('Failed to export dialogue:', error); } - }, [nodes, edges, saveDialogueGraph, dialogueId, viewport, contentLocale, exportDialogue]); + }, [captureSave, acknowledgeSave, saveDialogueGraph, dialogueId, viewport, contentLocale, exportDialogue]); const handleOpenLastExportPath = useCallback(async () => { const lastExportPath = String(dialogue?.lastExportPath || '').trim(); @@ -2520,7 +2460,7 @@ function DialogueEditorPage() { left={ <> - @@ -2545,6 +2485,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="rounded-full" + aria-label={t('accessibility.editorCommands')} data-tour="save-button" onClick={() => setCommandPaletteOpen(true)} > @@ -2566,6 +2507,8 @@ function DialogueEditorPage() { > @@ -2611,7 +2554,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="h-8 w-8 rounded-full" - onClick={handleFocusStartNode} + aria-label={t('editor.nodeToolbar.backToStart')} onClick={handleFocusStartNode} > @@ -2664,7 +2607,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="h-8 w-8 rounded-full" - onClick={() => setIsMobilePanelOpen(true)} + aria-label={t('accessibility.editSelection')} onClick={() => setIsMobilePanelOpen(true)} > @@ -2673,7 +2616,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 rounded-full" - onClick={() => setIsCascadeDeleteOpen(true)} + aria-label={t('common.delete')} onClick={() => setIsCascadeDeleteOpen(true)} > @@ -2682,7 +2625,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="h-8 w-8 rounded-full" - onClick={() => setSelectedNodeId(null)} + aria-label={t('common.close')} onClick={() => setSelectedNodeId(null)} > @@ -2733,7 +2676,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="h-8 w-8 rounded-full" - onClick={() => setIsMobilePanelOpen(true)} + aria-label={t('accessibility.editSelection')} onClick={() => setIsMobilePanelOpen(true)} > @@ -2741,7 +2684,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 rounded-full" - onClick={handleMobileEdgeDelete} + aria-label={t('common.delete')} onClick={handleMobileEdgeDelete} > @@ -2749,7 +2692,7 @@ function DialogueEditorPage() { variant="ghost" size="icon" className="h-8 w-8 rounded-full" - onClick={() => setSelectedEdge(null)} + aria-label={t('common.close')} onClick={() => setSelectedEdge(null)} > @@ -3040,7 +2983,7 @@ function DialogueEditorPage() { href="https://reactflow.dev" target="_blank" rel="noopener noreferrer" - className="hover:text-primary transition-colors" + className="text-foreground underline-offset-4 hover:underline focus-visible:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground rounded-sm" > React Flow From 97dd2460df180b4b1259cc22398b60f0d8f90f24 Mon Sep 17 00:00:00 2001 From: Dominik Morse Date: Thu, 1 Oct 2026 11:31:23 +0200 Subject: [PATCH 15/22] U: Polish dialogue editor components Small updates to preview overlay, rows panel, modals, zoom slider and placeholder node. --- .../dialogue/DialoguePreviewOverlay.jsx | 82 +++++++++---------- src/components/dialogue/DialogueRowsPanel.jsx | 22 +++++ .../dialogue/NodeConnectionModal.jsx | 16 ++-- .../dialogue/NodeTypeSelectionModal.jsx | 12 +-- src/components/dialogue/ZoomSlider.jsx | 8 +- .../dialogue/nodes/PlaceholderNode.jsx | 2 + 6 files changed, 85 insertions(+), 57 deletions(-) diff --git a/src/components/dialogue/DialoguePreviewOverlay.jsx b/src/components/dialogue/DialoguePreviewOverlay.jsx index 4252a4d..a933399 100644 --- a/src/components/dialogue/DialoguePreviewOverlay.jsx +++ b/src/components/dialogue/DialoguePreviewOverlay.jsx @@ -84,10 +84,13 @@ export function DialoguePreviewOverlay({ const [isVisible, setIsVisible] = useState(open); const [isClosing, setIsClosing] = useState(false); const [speaker, setSpeaker] = useState(''); + const [speakerId, setSpeakerId] = useState(''); const [lineText, setLineText] = useState(''); const [choiceOptions, setChoiceOptions] = useState([]); const [currentNodeRef, setCurrentNodeRef] = useState(null); const [volume, setVolume] = useState(100); + const volumeRef = useRef(volume); + volumeRef.current = volume; const [hasCurrentAudio, setHasCurrentAudio] = useState(false); const [visitedNodeKeys, setVisitedNodeKeys] = useState([]); const [selectedBranches, setSelectedBranches] = useState({}); @@ -115,7 +118,9 @@ export function DialoguePreviewOverlay({ closeTimeout: null, isClosingLocked: false, }); - const wasOpenRef = useRef(false); + const sessionRef = useRef(0); + nodesRef.current = nodes; + edgesRef.current = edges; const safeRootDialogueId = useMemo(() => String(rootDialogueId || '').trim(), [rootDialogueId]); const currentNodeId = currentNodeRef?.nodeId || null; @@ -235,26 +240,16 @@ export function DialoguePreviewOverlay({ return Math.min(100, Math.round((visitedPlayableCount / selectedRouteNodeKeys.length) * 100)); }, [visitedPlayableCount, selectedRouteNodeKeys]); - const participantThumbnailByName = useMemo(() => { - const map = new Map(); - participants.forEach((participant) => { - const name = String(participant?.name || '').trim(); - if (!name || map.has(name)) return; - const thumbnailUrl = resolveParticipantThumbnailDataUrl(participant?.thumbnail); - if (thumbnailUrl) { - map.set(name, thumbnailUrl); - } - }); - return map; - }, [participants]); - const speakerThumbnailUrl = useMemo(() => { - const speakerName = String(speaker || '').trim(); - if (!speakerName) return ''; - return participantThumbnailByName.get(speakerName) || ''; - }, [participantThumbnailByName, speaker]); + const matches = participants.filter((participant) => speakerId ? participant.id === speakerId : participant.name === speaker); + return matches.length === 1 ? resolveParticipantThumbnailDataUrl(matches[0].thumbnail) || '' : ''; + }, [participants, speaker, speakerId]); const clearTimer = useCallback(() => { + if (runtimeRef.current.animationFrame) { + window.cancelAnimationFrame(runtimeRef.current.animationFrame); + runtimeRef.current.animationFrame = null; + } if (runtimeRef.current.timer) { window.clearTimeout(runtimeRef.current.timer); runtimeRef.current.timer = null; @@ -315,6 +310,7 @@ export function DialoguePreviewOverlay({ const closePreview = useCallback( ({ withAnimation = true } = {}) => { if (runtimeRef.current.isClosingLocked) return; + sessionRef.current += 1; runtimeRef.current.isClosingLocked = true; clearTimer(); stopAudio(); @@ -327,6 +323,7 @@ export function DialoguePreviewOverlay({ setCurrentNodeRef(null); setLineText(''); setSpeaker(''); + setSpeakerId(''); setHasCurrentAudio(false); setCurrentRowIndex(0); setCurrentRowCount(0); @@ -439,6 +436,7 @@ export function DialoguePreviewOverlay({ const ensureGraphLoaded = useCallback( async (dialogueId) => { + const session = sessionRef.current; const targetDialogueId = String(dialogueId || '').trim(); if (!targetDialogueId) return null; @@ -448,6 +446,7 @@ export function DialoguePreviewOverlay({ try { const loaded = await loadDialogueGraphForPreview(targetDialogueId); + if (session !== sessionRef.current) return null; const runtime = buildPreviewGraphRuntime(loaded?.nodes || [], loaded?.edges || []); graphCacheRef.current = { ...graphCacheRef.current, @@ -482,6 +481,7 @@ export function DialoguePreviewOverlay({ ); const goToNode = useCallback( (nodeRef) => { + const session = sessionRef.current; const dialogueKey = String(nodeRef?.dialogueId || '').trim(); const nodeKey = String(nodeRef?.nodeId || '').trim(); if (!dialogueKey || !nodeKey) { @@ -532,6 +532,7 @@ export function DialoguePreviewOverlay({ setCurrentRowProgressPercent(0); runtimeRef.current.timer = window.setTimeout(async () => { await ensureGraphLoaded(targetDialogueId); + if (session !== sessionRef.current) return; const childStartRef = getStartPreviewNodeRef( targetDialogueId, scenarioContextRef.current @@ -551,6 +552,7 @@ export function DialoguePreviewOverlay({ const branchOptions = getBranchChoiceOptions(activeNodeRef); setSpeaker(getSpeakerForPreview(node, t)); + setSpeakerId(node.data?.participantId || ''); setLineText( t('editor.nodes.delayDescription', { count: Number(node.data?.duration) || 1, @@ -576,7 +578,8 @@ export function DialoguePreviewOverlay({ setChoiceOptions(branchOptions); return; } - window.requestAnimationFrame(() => { + runtimeRef.current.animationFrame = window.requestAnimationFrame(() => { + if (session !== sessionRef.current) return; goToNode(nextNodeRefs[0]); }); }, ROW_COMPLETION_HOLD_MS); @@ -614,6 +617,7 @@ export function DialoguePreviewOverlay({ if (rows.length === 0) { setLineText(''); setSpeaker(getSpeakerForPreview(node, t)); + setSpeakerId(node.data?.participantId || ''); setHasCurrentAudio(false); setCurrentRowIndex(0); setCurrentRowCount(0); @@ -627,6 +631,7 @@ export function DialoguePreviewOverlay({ setChoiceOptions([]); setCurrentRowCount(rows.length); const renderNextRow = () => { + if (session !== sessionRef.current) return; const row = rows[index]; if (!row) { setCurrentRowProgressPercent(0); @@ -636,6 +641,7 @@ export function DialoguePreviewOverlay({ stopAudio(); setSpeaker(getSpeakerForPreview(node, t)); + setSpeakerId(node.data?.participantId || ''); setLineText(row.text || ''); setCurrentRowIndex(index + 1); @@ -643,7 +649,7 @@ export function DialoguePreviewOverlay({ setHasCurrentAudio(Boolean(audioSource?.url)); if (audioSource?.url) { const audio = new Audio(audioSource.url); - audio.volume = Math.max(0, Math.min(1, volume / 100)); + audio.volume = Math.max(0, Math.min(1, volumeRef.current / 100)); runtimeRef.current.audio = audio; if (audioSource.revokeOnCleanup) { runtimeRef.current.audioObjectUrl = audioSource.url; @@ -658,7 +664,7 @@ export function DialoguePreviewOverlay({ runtimeRef.current.timer = window.setTimeout(() => { finishRowProgress(progressToken); runtimeRef.current.timer = window.setTimeout(() => { - window.requestAnimationFrame(renderNextRow); + runtimeRef.current.animationFrame = window.requestAnimationFrame(renderNextRow); }, ROW_COMPLETION_HOLD_MS); }, durationMs); }; @@ -675,7 +681,6 @@ export function DialoguePreviewOverlay({ onNodeFocus, stopAudio, t, - volume, ensureGraphLoaded, startRowProgress, finishRowProgress, @@ -738,9 +743,15 @@ export function DialoguePreviewOverlay({ return cache; }, [safeRootDialogueId, loadDialogueGraphForPreview]); + // A playback session owns timers/audio. Changing volume or callback identities + // must not tear it down; StrictMode cleanup/setup must always start it afresh. + const sessionCallbacksRef = useRef(null); + sessionCallbacksRef.current = { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, preloadGraphCache, stopAudio }; useEffect(() => { + const { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, preloadGraphCache, stopAudio } = sessionCallbacksRef.current; + const runtime = runtimeRef.current; + sessionRef.current += 1; if (!open) { - wasOpenRef.current = false; if (runtimeRef.current.closeTimeout) { window.clearTimeout(runtimeRef.current.closeTimeout); runtimeRef.current.closeTimeout = null; @@ -751,9 +762,6 @@ export function DialoguePreviewOverlay({ setIsVisible(false); return; } - if (wasOpenRef.current) return; - wasOpenRef.current = true; - let cancelled = false; setIsVisible(true); setIsClosing(false); @@ -830,24 +838,14 @@ export function DialoguePreviewOverlay({ return () => { cancelled = true; + sessionRef.current += 1; clearTimer(); stopAudio(); + window.clearTimeout(runtime.closeTimeout); + runtime.closeTimeout = null; + runtime.isClosingLocked = false; }; - }, [ - open, - clearTimer, - closePreview, - goToNode, - getStartPreviewNodeRef, - preloadGraphCache, - safeRootDialogueId, - stopAudio, - ]); - - useEffect(() => { - nodesRef.current = nodes; - edgesRef.current = edges; - }, [nodes, edges]); + }, [open, safeRootDialogueId]); useEffect(() => { scenarioContextRef.current = scenarioContext; @@ -1060,7 +1058,7 @@ export function DialoguePreviewOverlay({ } } setActiveBranchNodeRef(null); - window.setTimeout(() => { + runtimeRef.current.timer = window.setTimeout(() => { goToNode(choice.nodeRef); }, NODE_TRANSITION_DELAY_MS); }} diff --git a/src/components/dialogue/DialogueRowsPanel.jsx b/src/components/dialogue/DialogueRowsPanel.jsx index 76ce5ee..6dfbee0 100644 --- a/src/components/dialogue/DialogueRowsPanel.jsx +++ b/src/components/dialogue/DialogueRowsPanel.jsx @@ -32,6 +32,7 @@ export function DialogueRowsPanel({ const audioRefs = useRef({}); // Store audio element references by row ID const audioUrlsRef = useRef({}); // Store blob URLs by row ID for cleanup const audioUrlOwnedRef = useRef({}); // Track if URL was created by this component + const audioSourcesRef = useRef({}); const [audioUrls, setAudioUrls] = useState({}); // Ensure each row has a stable ID to prevent audio collisions and bad rebinding. @@ -59,6 +60,17 @@ export function DialogueRowsPanel({ dialogueRows.forEach((row) => { if (row.audioFile) { currentRowIds.add(row.id); + const source = row.audioFile.blob || row.audioFile.dataUrl || row.audioFile.url; + if (source && audioSourcesRef.current[row.id] === source && audioUrlsRef.current[row.id]) return; + const previousAudio = audioRefs.current[row.id]; + if (previousAudio) { + previousAudio.pause(); + previousAudio.src = ''; + } + if (audioUrlOwnedRef.current[row.id] && audioUrlsRef.current[row.id]) URL.revokeObjectURL(audioUrlsRef.current[row.id]); + delete audioUrlsRef.current[row.id]; + delete audioUrlOwnedRef.current[row.id]; + audioSourcesRef.current[row.id] = source; let url = null; let isOwned = false; @@ -118,12 +130,15 @@ export function DialogueRowsPanel({ } delete audioUrlsRef.current[rowId]; delete audioUrlOwnedRef.current[rowId]; + delete audioSourcesRef.current[rowId]; } }); // Clean up stale audio element refs. Object.keys(audioRefs.current).forEach((rowId) => { if (!currentRowIds.has(rowId)) { + audioRefs.current[rowId]?.pause(); + if (audioRefs.current[rowId]) audioRefs.current[rowId].src = ''; delete audioRefs.current[rowId]; } }); @@ -135,12 +150,18 @@ export function DialogueRowsPanel({ useEffect(() => { const urlsRef = audioUrlsRef.current; const ownedRef = audioUrlOwnedRef.current; + const sourceRef = audioSourcesRef.current; + const players = audioRefs.current; return () => { + Object.values(players).forEach((player) => { player?.pause(); if (player) player.src = ''; }); Object.keys(urlsRef).forEach((rowId) => { const url = urlsRef[rowId]; if (url && ownedRef[rowId] && url.startsWith('blob:')) { URL.revokeObjectURL(url); } + delete urlsRef[rowId]; + delete ownedRef[rowId]; + delete sourceRef[rowId]; }); }; }, []); // Empty dependency array = only runs on mount/unmount @@ -397,6 +418,7 @@ export function DialogueRowsPanel({ } delete audioUrlsRef.current[row.id]; delete audioUrlOwnedRef.current[row.id]; + delete audioSourcesRef.current[row.id]; setAudioUrls((prev) => { const next = { ...prev }; delete next[row.id]; diff --git a/src/components/dialogue/NodeConnectionModal.jsx b/src/components/dialogue/NodeConnectionModal.jsx index 0e62940..8653913 100644 --- a/src/components/dialogue/NodeConnectionModal.jsx +++ b/src/components/dialogue/NodeConnectionModal.jsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import { Drawer, @@ -17,6 +18,7 @@ import { getNodeDefinition } from '@/config/dialogueNodes'; * Uses a Vaul bottom-drawer with a NativeSelect for node selection. */ export function NodeConnectionModal({ open, onOpenChange, candidateNodes, onSelectNode }) { + const { t } = useTranslation(); const [selectedId, setSelectedId] = useState(''); const handleOpenChange = (v) => { @@ -36,22 +38,22 @@ export function NodeConnectionModal({ open, onOpenChange, candidateNodes, onSele - Connect to Existing Node - Select a node to connect to + {t('accessibility.connectExisting')} + {t('accessibility.chooseConnection')}
{nodes.length === 0 ? (

- No connectable nodes available + {t('accessibility.noConnections')}

) : ( setSelectedId(e.target.value)} > {nodes.map((n) => { const def = getNodeDefinition(n.type) || {}; @@ -69,10 +71,10 @@ export function NodeConnectionModal({ open, onOpenChange, candidateNodes, onSele diff --git a/src/components/dialogue/NodeTypeSelectionModal.jsx b/src/components/dialogue/NodeTypeSelectionModal.jsx index fcbde3d..ccb413c 100644 --- a/src/components/dialogue/NodeTypeSelectionModal.jsx +++ b/src/components/dialogue/NodeTypeSelectionModal.jsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { MessageCircle, User, CornerUpLeft, CheckCircle2, Clock, ExternalLink, Link2 } from 'lucide-react'; import { Drawer, @@ -16,6 +17,7 @@ import { getCreatableNodeDefinitions } from '@/config/dialogueNodes'; * Uses a bottom Drawer so the list is never clipped on small screens. */ export function NodeTypeSelectionModal({ open, onOpenChange, onSelectType, onConnectExisting }) { + const { t } = useTranslation(); const iconMap = { messageCircle: MessageCircle, user: User, @@ -37,9 +39,9 @@ export function NodeTypeSelectionModal({ open, onOpenChange, onSelectType, onCon {/* Static header */} - Select Node Type + {t('accessibility.nodeType')} - Choose the type of node you want to add to the dialogue + {t('accessibility.nodeTypeDescription')} @@ -82,9 +84,9 @@ export function NodeTypeSelectionModal({ open, onOpenChange, onSelectType, onCon
- Connect to Existing + {t('accessibility.connectExisting')} - Link to a node already in this dialogue + {t('accessibility.chooseConnection')}
@@ -94,7 +96,7 @@ export function NodeTypeSelectionModal({ open, onOpenChange, onSelectType, onCon {/* Static footer with dismiss */}
diff --git a/src/components/dialogue/ZoomSlider.jsx b/src/components/dialogue/ZoomSlider.jsx index 377d356..0248b12 100644 --- a/src/components/dialogue/ZoomSlider.jsx +++ b/src/components/dialogue/ZoomSlider.jsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useReactFlow } from '@xyflow/react'; import { Minus, Plus } from 'lucide-react'; @@ -11,6 +12,7 @@ export function ZoomSlider({ maxZoom = 2, step = 0.1, }) { + const { t } = useTranslation(); const { setViewport, getZoom, getViewport } = useReactFlow(); const currentZoom = getZoom(); @@ -37,7 +39,7 @@ export function ZoomSlider({ @@ -53,13 +55,13 @@ export function ZoomSlider({ writingMode: 'vertical-lr', direction: 'rtl', }} - title={`Zoom: ${Math.round(currentZoom * 100)}%`} + aria-label={t('accessibility.zoom')} aria-valuetext={`${Math.round(currentZoom * 100)}%`} /> diff --git a/src/components/dialogue/nodes/PlaceholderNode.jsx b/src/components/dialogue/nodes/PlaceholderNode.jsx index d94c94d..828af65 100644 --- a/src/components/dialogue/nodes/PlaceholderNode.jsx +++ b/src/components/dialogue/nodes/PlaceholderNode.jsx @@ -26,6 +26,8 @@ function PlaceholderNode({ id, data, selected, positionAbsoluteX, positionAbsolu ${selected ? 'border-primary shadow-lg' : 'border-border'} `} onClick={handleClick} + role="button" tabIndex={0} aria-label={t('editor.placeholder.addNode')} + onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); handleClick(); } }} > {/* Top Handle */} Date: Thu, 1 Oct 2026 11:31:23 +0200 Subject: [PATCH 16/22] A: Add project recovery and archive import UI Add archive import dialog, recovery panel and summary; surface them in project sections and routes. --- .../projects/ArchiveImportDialog.jsx | 30 +++++++++++ .../projects/ProjectRecoveryPanel.jsx | 43 +++++++++++++++ src/components/projects/RecoverySummary.jsx | 52 +++++++++++++++++++ .../projects/sections/CategoriesSection.jsx | 6 ++- .../projects/sections/DialoguesSection.jsx | 20 +++---- .../projects/sections/OverviewSection.jsx | 9 ++-- .../projects/sections/ParticipantsSection.jsx | 21 ++++---- .../sections/ProjectSettingsSection.jsx | 6 ++- src/routes/index.jsx | 49 +++++++---------- src/routes/projects/$projectId/index.jsx | 20 +++---- 10 files changed, 185 insertions(+), 71 deletions(-) create mode 100644 src/components/projects/ArchiveImportDialog.jsx create mode 100644 src/components/projects/ProjectRecoveryPanel.jsx create mode 100644 src/components/projects/RecoverySummary.jsx diff --git a/src/components/projects/ArchiveImportDialog.jsx b/src/components/projects/ArchiveImportDialog.jsx new file mode 100644 index 0000000..2a2ab54 --- /dev/null +++ b/src/components/projects/ArchiveImportDialog.jsx @@ -0,0 +1,30 @@ +import { describeError } from '@/lib/errorPresentation'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +/** Import always defaults to a copy. Replacement names its intended target. */ +export function ArchiveImportDialog({ file, targets = [], kind, onClose, onImport }) { + const { t } = useTranslation(); + const [mode, setMode] = useState('copy'); + const [targetId, setTargetId] = useState(targets[0]?.id || ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const returnFocus = useRef(null); + return { if (!open && !busy) onClose(); }}> + { returnFocus.current = document.activeElement; }} onCloseAutoFocus={(event) => { if (returnFocus.current?.isConnected) { event.preventDefault(); returnFocus.current.focus(); } }}> + {t('archive.importTitle', { defaultValue: 'Import archive' })}{file?.name} + + {targets.length > 0 && <> + + {mode === 'replace' && <> + +

{t('archive.replaceWarning', { defaultValue: 'The selected item and its graph content will be replaced. Export a backup first if you want to keep the current version.' })}

+ } + } + {error &&

{t('archive.failed')} {describeError(error, t)}

} + +
+
; +} diff --git a/src/components/projects/ProjectRecoveryPanel.jsx b/src/components/projects/ProjectRecoveryPanel.jsx new file mode 100644 index 0000000..2f6fb95 --- /dev/null +++ b/src/components/projects/ProjectRecoveryPanel.jsx @@ -0,0 +1,43 @@ +import { describeError } from '@/lib/errorPresentation'; +import { useTranslation } from 'react-i18next'; +import { serializeRecoveryEvidence } from '@/lib/recoveryEvidence'; +import { useEffect, useState } from 'react'; +import { getRepositoryContext, getRecoveryDiagnostics, revalidateProjectRecovery, restoreQuarantinedNode, repairIdentityReference, readProjectRecords } from '@/lib/db'; +import { Button } from '@/components/ui/button'; +import { NativeSelect } from '@/components/ui/native-select'; +import { Textarea } from '@/components/ui/textarea'; + +export function ProjectRecoveryPanel({ projectId }) { + const { t } = useTranslation(); + const [diagnostics, setDiagnostics] = useState([]), [records, setRecords] = useState(null), [error, setError] = useState(''), [busy, setBusy] = useState(false), [drafts, setDrafts] = useState({}); + useEffect(() => { + let active = true; + (async () => { const context = await getRepositoryContext(); const data = await serializeRecoveryEvidence(await getRecoveryDiagnostics(projectId, context)); const source = await readProjectRecords(projectId, context); context.assertCurrent(); if (active) { setDiagnostics(data); setRecords(source); } })().catch((failure) => { if (active && failure.code !== 'STALE_PROFILE') setError(failure); }); + return () => { active = false; }; + }, [projectId]); + const run = async (operation) => { + const context = await getRepositoryContext(); setBusy(true); setError(''); + try { await operation(context); const data = await serializeRecoveryEvidence(await getRecoveryDiagnostics(projectId, context)); const source = await readProjectRecords(projectId, context); context.assertCurrent(); setDiagnostics(data); setRecords(source); } + catch (failure) { if (!context.signal.aborted) setError(failure); } + finally { if (!context.signal.aborted) setBusy(false); } + }; + if (!diagnostics.length && !error) return null; + return
+

{t('recovery.title')} ({diagnostics.length})

+

{t('recovery.description')}

+ {error &&

{t('recovery.failed')}

{t('recovery.details')}{describeError(error, t)}
} + + {diagnostics.map((diagnostic) => { + const options = diagnostic.table === 'participants' && diagnostic.path === 'categoryId' ? records?.categories : diagnostic.table === 'nodes' && (diagnostic.path === 'participantId' || diagnostic.rowId) ? records?.participants : null; + const quarantine = diagnostic.table === 'nodes' && diagnostic.code === 'duplicate_identity'; + return
+ {describeError(diagnostic, t)} +

{t('recovery.record')}: {diagnostic.original?.id || t('recovery.unknown')} · {diagnostic.code}

+ {t('recovery.download')} + {options &&
setDrafts((current) => ({ ...current, [diagnostic.id]: event.target.value }))}>{options.map((record) => )}
} + {quarantine &&

{t('recovery.restoreDescription')}