diff --git a/.agents/skills/fiddy-verify/SKILL.md b/.agents/skills/fiddy-verify/SKILL.md new file mode 100644 index 0000000..8d45d6e --- /dev/null +++ b/.agents/skills/fiddy-verify/SKILL.md @@ -0,0 +1,40 @@ +--- +name: fiddy-verify +description: Run and report the Fiddy repository verification loop. Use when Codex is finishing changes, checking repo health, validating docs/scripts/config updates, or deciding which lint/typecheck/test/build commands are appropriate for this Express/Vite/npm project. +--- + +# Fiddy Verification + +Use this workflow from the repo root. + +## Before Running Commands +- Read `AGENTS.md`, `PROJECT_INSTRUCTIONS.md`, and any doc related to the touched area. +- Check `git status --short --branch` so user work is not mistaken for Codex changes. +- Do not print real `.env` values. Inspect keys only if environment context is needed. +- Do not run DB migrations unless the user explicitly asked for migration execution. + +## Choose Checks +- Docs-only changes: validate affected Markdown links/content where practical, then run JSON/script sanity checks if package files changed. +- Root or frontend script changes: run `npm run lint`, `npm run typecheck`, and the relevant build command. +- Backend behavior changes: run `npm test`; add focused Jest/Supertest coverage for changed API behavior. +- Frontend behavior changes: run `npm run lint`, `npm run typecheck`, and focused Playwright tests when a browser flow changed. +- Dependency or lockfile changes: run `npm run audit` after install/update commands. +- Migration changes: run `npm run db:migrate:stale:check` and `npm run db:migrate:verify` only against the intended external DB environment. + +## Default Safe Loop +Run these when dependencies are already installed and the touched files justify them: + +```bash +npm run lint +npm run typecheck +npm run audit +npm test +npm run build:backend +npm run build:frontend +``` + +## Report +- List exact commands run and pass/fail. +- For failures, include the short relevant error and whether it appears caused by the current changes. +- If a check is skipped, state the concrete reason. +- End with unresolved risks and the smallest useful next step. diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/main-deploy.yml similarity index 78% rename from .gitea/workflows/deploy.yml rename to .gitea/workflows/main-deploy.yml index 839682b..9aa02f0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/main-deploy.yml @@ -5,7 +5,7 @@ on: branches: [ "main" ] env: - REGISTRY: git.nicosaya.com/nalalangan/costco-grocery-list + REGISTRY: git.nicosaya.com/nalalangan/grocery-app jobs: build: @@ -13,26 +13,34 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22.12.0 # ------------------------- - # 🔹 BACKEND TESTS + # Verification gate # ------------------------- - - name: Install backend dependencies - working-directory: backend - run: npm ci + - name: Install dependencies + run: | + npm ci + npm --prefix backend ci + npm --prefix frontend ci - - name: Run backend tests - working-directory: backend - run: npm test --if-present + - name: Run reliability verification + run: | + npm run audit + npm run lint + npm run typecheck + npm test + npm run db:migrate:stale:check + npm run build:backend + npm run build:frontend # ------------------------- - # 🔹 Docker Login + # Docker Login # ------------------------- - name: Docker login run: | @@ -40,7 +48,7 @@ jobs: -u "${{ secrets.REGISTRY_USER }}" --password-stdin # ------------------------- - # 🔹 Build Backend Image + # Build Backend Image # ------------------------- - name: Build Backend Image run: | @@ -55,7 +63,7 @@ jobs: docker push $REGISTRY/backend:latest # ------------------------- - # 🔹 Build Frontend Image + # Build Frontend Image # ------------------------- - name: Build Frontend Image run: | @@ -75,7 +83,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install SSH key run: | @@ -117,12 +125,11 @@ jobs: echo "Deployment job finished with status: $STATUS" if [ "$STATUS" = "success" ]; then - MSG="🚀 Costco App Deployment succeeded: $IMAGE_NAME:${{ github.sha }}" + MSG="Costco App Deployment succeeded: $REGISTRY:${{ github.sha }}" else - MSG="❌ Costco App Deployment FAILED: $IMAGE_NAME:${{ github.sha }}" + MSG="Costco App Deployment FAILED: $REGISTRY:${{ github.sha }}" fi curl -d "$MSG" \ https://ntfy.nicosaya.com/gitea - \ No newline at end of file diff --git a/.gitea/workflows/new-deploy.yml b/.gitea/workflows/new-deploy.yml new file mode 100644 index 0000000..0c0287e --- /dev/null +++ b/.gitea/workflows/new-deploy.yml @@ -0,0 +1,157 @@ +name: Build & Deploy Costco Grocery List + +on: + push: + branches: [ "main-new" ] + +env: + REGISTRY: git.nicosaya.com/nalalangan/grocery-app + # REGISTRY: grocery-app + IMAGE_TAG: main-new + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.12.0 + + # ------------------------- + # Verification gate + # ------------------------- + - name: Install dependencies + run: | + npm ci + npm --prefix backend ci + npm --prefix frontend ci + + - name: Run reliability verification + run: | + npm run audit + npm run lint + npm run typecheck + npm test + npm run db:migrate:stale:check + npm run build:backend + npm run build:frontend + + # ------------------------- + # Docker Login + # ------------------------- + - name: Docker login + run: | + echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \ + -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + # ------------------------- + # Build Backend Image + # ------------------------- + - name: Build Backend Image + run: | + docker build \ + -t $REGISTRY/backend:${{ github.sha }} \ + -t $REGISTRY/backend:${{ env.IMAGE_TAG }} \ + -f backend/Dockerfile backend/ + + - name: Push Backend Image + run: | + docker push $REGISTRY/backend:${{ github.sha }} + docker push $REGISTRY/backend:${{ env.IMAGE_TAG }} + + # ------------------------- + # Build Frontend Image + # ------------------------- + - name: Build Frontend Image + run: | + docker build \ + -t $REGISTRY/frontend:${{ github.sha }} \ + -t $REGISTRY/frontend:${{ env.IMAGE_TAG }} \ + -f frontend/Dockerfile.dev frontend/ + + - name: Push Frontend Image + run: | + docker push $REGISTRY/frontend:${{ github.sha }} + docker push $REGISTRY/frontend:${{ env.IMAGE_TAG }} + + verify-images: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Docker login + run: | + echo "${{ secrets.REGISTRY_PASS }}" | docker login $REGISTRY \ + -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Verify backend image tags exist + run: | + docker manifest inspect $REGISTRY/backend:${{ github.sha }} >/dev/null + docker manifest inspect $REGISTRY/backend:${{ env.IMAGE_TAG }} >/dev/null + + - name: Verify frontend image tags exist + run: | + docker manifest inspect $REGISTRY/frontend:${{ github.sha }} >/dev/null + docker manifest inspect $REGISTRY/frontend:${{ env.IMAGE_TAG }} >/dev/null + + deploy: + needs: verify-images + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Install SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + # --------------------------------------------------------- + # 1. Upload docker-compose.yml to the production directory + # --------------------------------------------------------- + - name: Upload docker-compose.yml + run: | + ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p /opt/costco-app-new" + scp docker-compose.new.yml \ + ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/costco-app-new/docker-compose.yml + + # --------------------------------------------------------- + # 2. Deploy using the uploaded compose file + # --------------------------------------------------------- + - name: Deploy via SSH + run: | + ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF' + cd /opt/costco-app-new + docker compose pull + docker compose up -d --remove-orphans + docker image prune -f + EOF + + notify: + needs: deploy + runs-on: ubuntu-latest + if: always() + + steps: + - name: Notify ntfy + run: | + STATUS="${{ needs.deploy.result }}" + echo "Deployment job finished with status: $STATUS" + + if [ "$STATUS" = "success" ]; then + MSG="Grocery App Deployment succeeded: $REGISTRY:${{ github.sha }}" + else + MSG="Grocery App Deployment FAILED: $REGISTRY:${{ github.sha }}" + fi + + curl -d "$MSG" \ + https://ntfy.nicosaya.com/gitea + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 996b319..a8b014e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,197 +1,21 @@ -# Costco Grocery List - AI Agent Instructions +# Copilot Compatibility Instructions -## Architecture Overview +## Precedence +- Source of truth: `PROJECT_INSTRUCTIONS.md` (repo root). +- Agent workflow constraints: `AGENTS.md` (repo root). +- Bugfix protocol: `DEBUGGING_INSTRUCTIONS.md` (repo root). -This is a full-stack grocery list management app with **role-based access control (RBAC)**: -- **Backend**: Node.js + Express + PostgreSQL (port 5000) -- **Frontend**: React 19 + TypeScript + Vite (port 3000/5173) -- **Deployment**: Docker Compose with separate dev/prod configurations +If any guidance in this file conflicts with the root instruction files, follow the root instruction files. -### Key Design Patterns +## Current stack note +This repository is currently: +- Backend: Express (`backend/`) +- Frontend: React + Vite (`frontend/`) -**Three-tier RBAC system** (`viewer`, `editor`, `admin`): -- `viewer`: Read-only access to grocery lists -- `editor`: Can add items and mark as bought -- `admin`: Full user management via admin panel -- Roles defined in [backend/models/user.model.js](backend/models/user.model.js) and mirrored in [frontend/src/constants/roles.js](frontend/src/constants/roles.js) +Apply architecture intent from `PROJECT_INSTRUCTIONS.md` using the current stack mapping in: +- `docs/AGENTIC_CONTRACT_MAP.md` -**Middleware chain pattern** for protected routes: -```javascript -router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem); -``` -- `auth` middleware extracts JWT from `Authorization: Bearer ` header -- `requireRole` checks if user's role matches allowed roles -- See [backend/routes/list.routes.js](backend/routes/list.routes.js) for examples - -**Frontend route protection**: -- ``: Requires authentication, redirects to `/login` if no token -- ``: Requires specific role(s), redirects to `/` if unauthorized -- Example in [frontend/src/App.jsx](frontend/src/App.jsx) - -## Database Schema - -**PostgreSQL server runs externally** - not in Docker Compose. Connection configured in [backend/.env](backend/.env) via standard environment variables. - -**Tables** (inferred from models, no formal migrations): -- **users**: `id`, `username`, `password` (bcrypt hashed), `name`, `role` -- **grocery_list**: `id`, `item_name`, `quantity`, `bought`, `added_by` -- **grocery_history**: Junction table tracking which users added which items - -**Important patterns**: -- No migration system - schema changes are manual SQL -- Items use case-insensitive matching (`ILIKE`) to prevent duplicates -- JOINs with `ARRAY_AGG` for multi-contributor queries (see [backend/models/list.model.js](backend/models/list.model.js)) - -## Development Workflow - -### Local Development -```bash -# Start all services with hot-reload against LOCAL database -docker-compose -f docker-compose.dev.yml up - -# Backend runs nodemon (watches backend/*.js) -# Frontend runs Vite dev server with HMR on port 3000 -``` - -**Key dev setup details**: -- Volume mounts preserve `node_modules` in containers while syncing source code -- Backend uses `Dockerfile` (standard) with `npm run dev` override -- Frontend uses `Dockerfile.dev` with `CHOKIDAR_USEPOLLING=true` for file watching -- Both connect to **external PostgreSQL server** (configured in `backend/.env`) -- No database container in compose - DB is managed separately - -### Production Build -```bash -# Local production build (for testing) -docker-compose -f docker-compose.prod.yml up --build - -# Actual production uses pre-built images -docker-compose up # Pulls from private registry -``` - -### CI/CD Pipeline (Gitea Actions) - -See [.gitea/workflows/deploy.yml](.gitea/workflows/deploy.yml) for full workflow: - -**Build stage** (on push to `main`): -1. Run backend tests (`npm test --if-present`) -2. Build backend image with tags: `:latest` and `:` -3. Build frontend image with tags: `:latest` and `:` -4. Push both images to private registry - -**Deploy stage**: -1. SSH to production server -2. Upload `docker-compose.yml` to deployment directory -3. Pull latest images and restart containers with `docker compose up -d` -4. Prune old images - -**Notify stage**: -- Sends deployment status via webhook - -**Required secrets**: -- `REGISTRY_USER`, `REGISTRY_PASS`: Docker registry credentials -- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_KEY`: SSH deployment credentials - -### Backend Scripts -- `npm run dev`: Start with nodemon -- `npm run build`: esbuild compilation + copy public assets to `dist/` -- `npm test`: Run Jest tests (currently no tests exist) - -### Frontend Scripts -- `npm run dev`: Vite dev server (port 5173) -- `npm run build`: TypeScript compilation + Vite production build - -### Docker Configurations - -**docker-compose.yml** (production): -- Pulls pre-built images from private registry -- Backend on port 5000, frontend on port 3000 (nginx serves on port 80) -- Requires `backend.env` and `frontend.env` files - -**docker-compose.dev.yml** (local development): -- Builds images locally from Dockerfile/Dockerfile.dev -- Volume mounts for hot-reload: `./backend:/app` and `./frontend:/app` -- Named volumes preserve `node_modules` between rebuilds -- Backend uses `backend/.env` directly -- Frontend uses `Dockerfile.dev` with polling enabled for cross-platform compatibility - -**docker-compose.prod.yml** (local production testing): -- Builds images locally using production Dockerfiles -- Backend: Standard Node.js server -- Frontend: Multi-stage build with nginx serving static files - -## Configuration & Environment - -**Backend** ([backend/.env](backend/.env)): -- Database connection variables (host, user, password, database name) -- `JWT_SECRET`: Token signing key -- `ALLOWED_ORIGINS`: Comma-separated CORS whitelist (supports static origins + `192.168.*.*` IP ranges) -- `PORT`: Server port (default 5000) - -**Frontend** (environment variables): -- `VITE_API_URL`: Backend base URL - -**Config accessed via**: -- Backend: `process.env.VAR_NAME` -- Frontend: `import.meta.env.VITE_VAR_NAME` (see [frontend/src/config.ts](frontend/src/config.ts)) - -## Authentication Flow - -1. User logs in → backend returns `{token, role, username}` ([backend/controllers/auth.controller.js](backend/controllers/auth.controller.js)) -2. Frontend stores in `localStorage` and `AuthContext` ([frontend/src/context/AuthContext.jsx](frontend/src/context/AuthContext.jsx)) -3. Axios interceptor auto-attaches `Authorization: Bearer ` header ([frontend/src/api/axios.js](frontend/src/api/axios.js)) -4. Backend validates JWT on protected routes ([backend/middleware/auth.js](backend/middleware/auth.js)) -5. On 401 "Invalid or expired token" response, frontend clears storage and redirects to login - -## Critical Conventions - -### Security Practices -- **Never expose credentials**: Do not hardcode or document actual values for `JWT_SECRET`, database passwords, API keys, or any sensitive configuration -- **No infrastructure details**: Avoid documenting specific IP addresses, domain names, deployment paths, or server locations in code or documentation -- **Environment variables**: Reference `.env` files conceptually - never include actual contents -- **Secrets in CI/CD**: Document that secrets are required, not their values -- **Code review**: Scan all changes for accidentally committed credentials before pushing - -### Backend -- **No SQL injection**: Always use parameterized queries (`$1`, `$2`, etc.) with [backend/db/pool.js](backend/db/pool.js) -- **Password hashing**: Use `bcryptjs` for hashing (see [backend/controllers/auth.controller.js](backend/controllers/auth.controller.js)) -- **CORS**: Dynamic origin validation in [backend/app.js](backend/app.js) allows configured origins + local IPs -- **Error responses**: Return JSON with `{message: "..."}` structure - -### Frontend -- **Mixed JSX/TSX**: Some components are `.jsx` (JavaScript), others `.tsx` (TypeScript) - maintain existing file extensions -- **API calls**: Use centralized `api` instance from [frontend/src/api/axios.js](frontend/src/api/axios.js), not raw axios -- **Role checks**: Access role from `AuthContext`, compare with constants from [frontend/src/constants/roles.js](frontend/src/constants/roles.js) -- **Navigation**: Use React Router's `` for redirects, not `window.location` (except in interceptor) - -## Common Tasks - -**Add a new protected route**: -1. Backend: Add route with `auth` + `requireRole(...)` middleware -2. Frontend: Add route in [frontend/src/App.jsx](frontend/src/App.jsx) wrapped in `` and/or `` - -**Access user info in backend controller**: -```javascript -const { id, role } = req.user; // Set by auth middleware -``` - -**Query grocery items with contributors**: -Use the JOIN pattern in [backend/models/list.model.js](backend/models/list.model.js) - aggregates user names via `grocery_history` table. - -## Testing - -**Backend**: -- Jest configured at root level ([package.json](package.json)) -- Currently **no test files exist** - testing infrastructure needs development -- CI/CD runs `npm test --if-present` but will pass if no tests found -- Focus area: API endpoint testing (use `supertest` with Express) - -**Frontend**: -- ESLint only (see [frontend/eslint.config.js](frontend/eslint.config.js)) -- No test runner configured -- Manual testing workflow in use - -**To add backend tests**: -1. Create `backend/__tests__/` directory -2. Use Jest + Supertest pattern for API tests -3. Mock database calls or use test database +## Safety reminders +- External DB only (`DATABASE_URL`), no DB container assumptions. +- No cron/worker additions unless explicitly approved. +- Never log secrets, receipt bytes, or full invite codes. diff --git a/.gitignore b/.gitignore index 57d4ffc..b5c8e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,15 @@ # Node dependencies node_modules/ -# Build output (if using a bundler or React later) -dist/ -build/ - -# Logs -npm-debug.log* +# Build output (if using a bundler or React later) +dist/ +build/ +playwright-report/ +test-results/ +.npm-cache/ +.playwright-browsers/ + +# Logs +npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/.vscode-extensions/extensions.json b/.vscode-extensions/extensions.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.vscode-extensions/extensions.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.vscode-user/CachedExtensionVSIXs/.trash/ritwickdey.liveserver-5.7.9 b/.vscode-user/CachedExtensionVSIXs/.trash/ritwickdey.liveserver-5.7.9 new file mode 100644 index 0000000..b66cd84 Binary files /dev/null and b/.vscode-user/CachedExtensionVSIXs/.trash/ritwickdey.liveserver-5.7.9 differ diff --git a/.vscode-user/CachedExtensionVSIXs/.trash/ritwickdey.liveserver-5.7.9.sigzip b/.vscode-user/CachedExtensionVSIXs/.trash/ritwickdey.liveserver-5.7.9.sigzip new file mode 100644 index 0000000..fd19020 Binary files /dev/null and b/.vscode-user/CachedExtensionVSIXs/.trash/ritwickdey.liveserver-5.7.9.sigzip differ diff --git a/.vscode-user/CachedProfilesData/__default__profile__/extensions.builtin.cache b/.vscode-user/CachedProfilesData/__default__profile__/extensions.builtin.cache new file mode 100644 index 0000000..79ef9b2 --- /dev/null +++ b/.vscode-user/CachedProfilesData/__default__profile__/extensions.builtin.cache @@ -0,0 +1 @@ +{"input":{"location":{"$mid":1,"fsPath":"c:\\Users\\Nico\\AppData\\Local\\Programs\\Microsoft VS Code\\591199df40\\resources\\app\\extensions","_sep":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions","scheme":"file"},"mtime":1770796446043,"profile":false,"type":0,"validate":true,"productVersion":"1.109.2","productDate":"2026-02-10T20:18:23.520Z","productCommit":"591199df409fbf59b4b52d5ad4ee0470152a9b31","devMode":false,"translations":{}},"result":[{"type":0,"identifier":{"id":"vscode.bat"},"manifest":{"name":"bat","displayName":"Windows Bat Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in Windows batch files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.52.0"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin mmims/language-batchfile grammars/batchfile.cson ./syntaxes/batchfile.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"bat","extensions":[".bat",".cmd"],"aliases":["Batch","bat"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"bat","scopeName":"source.batchfile","path":"./syntaxes/batchfile.tmLanguage.json"}],"snippets":[{"language":"bat","path":"./snippets/batchfile.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/bat","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.clojure"},"manifest":{"name":"clojure","displayName":"Clojure Language Basics","description":"Provides syntax highlighting and bracket matching in Clojure files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin atom/language-clojure grammars/clojure.cson ./syntaxes/clojure.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"clojure","aliases":["Clojure","clojure"],"extensions":[".clj",".cljs",".cljc",".cljx",".clojure",".edn"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"clojure","scopeName":"source.clojure","path":"./syntaxes/clojure.tmLanguage.json"}],"configurationDefaults":{"[clojure]":{"diffEditor.ignoreTrimWhitespace":false}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/clojure","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.coffeescript"},"manifest":{"name":"coffeescript","displayName":"CoffeeScript Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in CoffeeScript files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin atom/language-coffee-script grammars/coffeescript.cson ./syntaxes/coffeescript.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"coffeescript","extensions":[".coffee",".cson",".iced"],"aliases":["CoffeeScript","coffeescript","coffee"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"coffeescript","scopeName":"source.coffee","path":"./syntaxes/coffeescript.tmLanguage.json"}],"breakpoints":[{"language":"coffeescript"}],"snippets":[{"language":"coffeescript","path":"./snippets/coffeescript.code-snippets"}],"configurationDefaults":{"[coffeescript]":{"diffEditor.ignoreTrimWhitespace":false,"editor.defaultColorDecorators":"never"}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/coffeescript","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.configuration-editing"},"manifest":{"name":"configuration-editing","displayName":"Configuration Editing","description":"Provides capabilities (advanced IntelliSense, auto-fixing) in configuration files like settings, launch, and extension recommendation files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.0.0"},"icon":"images/icon.png","activationEvents":["onProfile","onProfile:github","onLanguage:json","onLanguage:jsonc"],"enabledApiProposals":["profileContentHandlers"],"main":"./dist/configurationEditingMain","browser":"./dist/browser/configurationEditingMain","capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"contributes":{"languages":[{"id":"jsonc","extensions":[".code-workspace","language-configuration.json","icon-theme.json","color-theme.json"],"filenames":["settings.json","launch.json","tasks.json","mcp.json","keybindings.json","extensions.json","argv.json","profiles.json","devcontainer.json",".devcontainer.json"]},{"id":"json","extensions":[".code-profile"]}],"jsonValidation":[{"fileMatch":"vscode://defaultsettings/keybindings.json","url":"vscode://schemas/keybindings"},{"fileMatch":"%APP_SETTINGS_HOME%/keybindings.json","url":"vscode://schemas/keybindings"},{"fileMatch":"%APP_SETTINGS_HOME%/profiles/*/keybindings.json","url":"vscode://schemas/keybindings"},{"fileMatch":"vscode://defaultsettings/*.json","url":"vscode://schemas/settings/default"},{"fileMatch":"%APP_SETTINGS_HOME%/settings.json","url":"vscode://schemas/settings/user"},{"fileMatch":"%APP_SETTINGS_HOME%/profiles/*/settings.json","url":"vscode://schemas/settings/profile"},{"fileMatch":"%MACHINE_SETTINGS_HOME%/settings.json","url":"vscode://schemas/settings/machine"},{"fileMatch":"%APP_WORKSPACES_HOME%/*/workspace.json","url":"vscode://schemas/workspaceConfig"},{"fileMatch":"**/*.code-workspace","url":"vscode://schemas/workspaceConfig"},{"fileMatch":"**/argv.json","url":"vscode://schemas/argv"},{"fileMatch":"/.vscode/settings.json","url":"vscode://schemas/settings/folder"},{"fileMatch":"/.vscode/launch.json","url":"vscode://schemas/launch"},{"fileMatch":"/.vscode/tasks.json","url":"vscode://schemas/tasks"},{"fileMatch":"/.vscode/mcp.json","url":"vscode://schemas/mcp"},{"fileMatch":"%APP_SETTINGS_HOME%/tasks.json","url":"vscode://schemas/tasks"},{"fileMatch":"%APP_SETTINGS_HOME%/snippets/*.json","url":"vscode://schemas/snippets"},{"fileMatch":"%APP_SETTINGS_HOME%/prompts/*.toolsets.jsonc","url":"vscode://schemas/toolsets"},{"fileMatch":"%APP_SETTINGS_HOME%/profiles/*/snippets/.json","url":"vscode://schemas/snippets"},{"fileMatch":"%APP_SETTINGS_HOME%/sync/snippets/preview/*.json","url":"vscode://schemas/snippets"},{"fileMatch":"**/*.code-snippets","url":"vscode://schemas/global-snippets"},{"fileMatch":"/.vscode/extensions.json","url":"vscode://schemas/extensions"},{"fileMatch":"devcontainer.json","url":"https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json"},{"fileMatch":".devcontainer.json","url":"https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json"},{"fileMatch":"%APP_SETTINGS_HOME%/globalStorage/ms-vscode-remote.remote-containers/nameConfigs/*.json","url":"./schemas/attachContainer.schema.json"},{"fileMatch":"%APP_SETTINGS_HOME%/globalStorage/ms-vscode-remote.remote-containers/imageConfigs/*.json","url":"./schemas/attachContainer.schema.json"},{"fileMatch":"**/quality/*/product.json","url":"vscode://schemas/vscode-product"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/configuration-editing","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.cpp"},"manifest":{"name":"cpp","displayName":"C/C++ Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in C/C++ files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ./build/update-grammars.js"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"c","extensions":[".c",".i"],"aliases":["C","c"],"configuration":"./language-configuration.json"},{"id":"cpp","extensions":[".cpp",".cppm",".cc",".ccm",".cxx",".cxxm",".c++",".c++m",".hpp",".hh",".hxx",".h++",".h",".ii",".ino",".inl",".ipp",".ixx",".tpp",".txx",".hpp.in",".h.in"],"aliases":["C++","Cpp","cpp"],"configuration":"./language-configuration.json"},{"id":"cuda-cpp","extensions":[".cu",".cuh"],"aliases":["CUDA C++"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"c","scopeName":"source.c","path":"./syntaxes/c.tmLanguage.json"},{"language":"cpp","scopeName":"source.cpp.embedded.macro","path":"./syntaxes/cpp.embedded.macro.tmLanguage.json"},{"language":"cpp","scopeName":"source.cpp","path":"./syntaxes/cpp.tmLanguage.json"},{"scopeName":"source.c.platform","path":"./syntaxes/platform.tmLanguage.json"},{"language":"cuda-cpp","scopeName":"source.cuda-cpp","path":"./syntaxes/cuda-cpp.tmLanguage.json"}],"problemPatterns":[{"name":"nvcc-location","regexp":"^(.*)\\((\\d+)\\):\\s+(warning|error):\\s+(.*)","kind":"location","file":1,"location":2,"severity":3,"message":4}],"problemMatchers":[{"name":"nvcc","owner":"cuda-cpp","fileLocation":["relative","${workspaceFolder}"],"pattern":"$nvcc-location"}],"snippets":[{"language":"c","path":"./snippets/c.code-snippets"},{"language":"cpp","path":"./snippets/cpp.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/cpp","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.csharp"},"manifest":{"name":"csharp","displayName":"C# Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in C# files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin dotnet/csharp-tmLanguage grammars/csharp.tmLanguage ./syntaxes/csharp.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"configurationDefaults":{"[csharp]":{"editor.maxTokenizationLineLength":2500}},"languages":[{"id":"csharp","extensions":[".cs",".csx",".cake"],"aliases":["C#","csharp"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"csharp","scopeName":"source.cs","path":"./syntaxes/csharp.tmLanguage.json","tokenTypes":{"meta.interpolation":"other"},"unbalancedBracketScopes":["keyword.operator.relational.cs","keyword.operator.arrow.cs","punctuation.accessor.pointer.cs","keyword.operator.bitwise.shift.cs","keyword.operator.assignment.compound.bitwise.cs"]}],"snippets":[{"language":"csharp","path":"./snippets/csharp.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/csharp","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.css"},"manifest":{"name":"css","displayName":"CSS Language Basics","description":"Provides syntax highlighting and bracket matching for CSS, LESS and SCSS files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin microsoft/vscode-css grammars/css.cson ./syntaxes/css.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"css","aliases":["CSS","css"],"extensions":[".css"],"mimetypes":["text/css"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"css","scopeName":"source.css","path":"./syntaxes/css.tmLanguage.json","tokenTypes":{"meta.function.url string.quoted":"other"}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/css","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.css-language-features"},"manifest":{"name":"css-language-features","displayName":"CSS Language Features","description":"Provides rich language support for CSS, LESS and SCSS files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.77.0"},"icon":"icons/css.png","activationEvents":["onLanguage:css","onLanguage:less","onLanguage:scss","onCommand:_css.applyCodeAction"],"main":"./client/dist/node/cssClientMain","browser":"./client/dist/browser/cssClientMain","capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"categories":["Programming Languages"],"contributes":{"configuration":[{"order":22,"id":"css","title":"CSS","properties":{"css.customData":{"type":"array","markdownDescription":"A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-css-languageservice/blob/master/docs/customData.md).\n\nVS Code loads custom data on startup to enhance its CSS support for CSS custom properties (variables), at-rules, pseudo-classes, and pseudo-elements you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.","default":[],"items":{"type":"string"},"scope":"resource"},"css.completion.triggerPropertyValueCompletion":{"type":"boolean","scope":"resource","default":true,"description":"By default, VS Code triggers property value completion after selecting a CSS property. Use this setting to disable this behavior."},"css.completion.completePropertyWithSemicolon":{"type":"boolean","scope":"resource","default":true,"description":"Insert semicolon at end of line when completing CSS properties."},"css.validate":{"type":"boolean","scope":"resource","default":true,"description":"Enables or disables all validations."},"css.hover.documentation":{"type":"boolean","scope":"resource","default":true,"description":"Show property and value documentation in CSS hovers."},"css.hover.references":{"type":"boolean","scope":"resource","default":true,"description":"Show references to MDN in CSS hovers."},"css.lint.compatibleVendorPrefixes":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"When using a vendor-specific prefix make sure to also include all other vendor-specific properties."},"css.lint.vendorPrefix":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"When using a vendor-specific prefix, also include the standard property."},"css.lint.duplicateProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Do not use duplicate style definitions."},"css.lint.emptyRules":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Do not use empty rulesets."},"css.lint.importStatement":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Import statements do not load in parallel."},"css.lint.boxModel":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Do not use `width` or `height` when using `padding` or `border`."},"css.lint.universalSelector":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"The universal selector (`*`) is known to be slow."},"css.lint.zeroUnits":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"No unit for zero needed."},"css.lint.fontFaceProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","markdownDescription":"`@font-face` rule must define `src` and `font-family` properties."},"css.lint.hexColorLength":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"error","description":"Hex colors must consist of 3, 4, 6 or 8 hex numbers."},"css.lint.argumentsInColorFunction":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"error","description":"Invalid number of parameters."},"css.lint.unknownProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Unknown property."},"css.lint.validProperties":{"type":"array","uniqueItems":true,"items":{"type":"string"},"scope":"resource","default":[],"description":"A list of properties that are not validated against the `unknownProperties` rule."},"css.lint.ieHack":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"IE hacks are only necessary when supporting IE7 and older."},"css.lint.unknownVendorSpecificProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Unknown vendor specific property."},"css.lint.propertyIgnoredDueToDisplay":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","markdownDescription":"Property is ignored due to the display. E.g. with `display: inline`, the `width`, `height`, `margin-top`, `margin-bottom`, and `float` properties have no effect."},"css.lint.important":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Avoid using `!important`. It is an indication that the specificity of the entire CSS has gotten out of control and needs to be refactored."},"css.lint.float":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Avoid using `float`. Floats lead to fragile CSS that is easy to break if one aspect of the layout changes."},"css.lint.idSelector":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Selectors should not contain IDs because these rules are too tightly coupled with the HTML."},"css.lint.unknownAtRules":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Unknown at-rule."},"css.trace.server":{"type":"string","scope":"window","enum":["off","messages","verbose"],"default":"off","description":"Traces the communication between VS Code and the CSS language server."},"css.format.enable":{"type":"boolean","scope":"window","default":true,"description":"Enable/disable default CSS formatter."},"css.format.newlineBetweenSelectors":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Separate selectors with a new line."},"css.format.newlineBetweenRules":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Separate rulesets by a blank line."},"css.format.spaceAroundSelectorSeparator":{"type":"boolean","scope":"resource","default":false,"markdownDescription":"Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`)."},"css.format.braceStyle":{"type":"string","scope":"resource","default":"collapse","enum":["collapse","expand"],"markdownDescription":"Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`)."},"css.format.preserveNewLines":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Whether existing line breaks before rules and declarations should be preserved."},"css.format.maxPreserveNewLines":{"type":["number","null"],"scope":"resource","default":null,"markdownDescription":"Maximum number of line breaks to be preserved in one chunk, when `#css.format.preserveNewLines#` is enabled."}}},{"id":"scss","order":24,"title":"SCSS (Sass)","properties":{"scss.completion.triggerPropertyValueCompletion":{"type":"boolean","scope":"resource","default":true,"description":"By default, VS Code triggers property value completion after selecting a CSS property. Use this setting to disable this behavior."},"scss.completion.completePropertyWithSemicolon":{"type":"boolean","scope":"resource","default":true,"description":"Insert semicolon at end of line when completing CSS properties."},"scss.validate":{"type":"boolean","scope":"resource","default":true,"description":"Enables or disables all validations."},"scss.hover.documentation":{"type":"boolean","scope":"resource","default":true,"description":"Show property and value documentation in SCSS hovers."},"scss.hover.references":{"type":"boolean","scope":"resource","default":true,"description":"Show references to MDN in SCSS hovers."},"scss.lint.compatibleVendorPrefixes":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"When using a vendor-specific prefix make sure to also include all other vendor-specific properties."},"scss.lint.vendorPrefix":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"When using a vendor-specific prefix, also include the standard property."},"scss.lint.duplicateProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Do not use duplicate style definitions."},"scss.lint.emptyRules":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Do not use empty rulesets."},"scss.lint.importStatement":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Import statements do not load in parallel."},"scss.lint.boxModel":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Do not use `width` or `height` when using `padding` or `border`."},"scss.lint.universalSelector":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"The universal selector (`*`) is known to be slow."},"scss.lint.zeroUnits":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"No unit for zero needed."},"scss.lint.fontFaceProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","markdownDescription":"`@font-face` rule must define `src` and `font-family` properties."},"scss.lint.hexColorLength":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"error","description":"Hex colors must consist of 3, 4, 6 or 8 hex numbers."},"scss.lint.argumentsInColorFunction":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"error","description":"Invalid number of parameters."},"scss.lint.unknownProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Unknown property."},"scss.lint.validProperties":{"type":"array","uniqueItems":true,"items":{"type":"string"},"scope":"resource","default":[],"description":"A list of properties that are not validated against the `unknownProperties` rule."},"scss.lint.ieHack":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"IE hacks are only necessary when supporting IE7 and older."},"scss.lint.unknownVendorSpecificProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Unknown vendor specific property."},"scss.lint.propertyIgnoredDueToDisplay":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","markdownDescription":"Property is ignored due to the display. E.g. with `display: inline`, the `width`, `height`, `margin-top`, `margin-bottom`, and `float` properties have no effect."},"scss.lint.important":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Avoid using `!important`. It is an indication that the specificity of the entire CSS has gotten out of control and needs to be refactored."},"scss.lint.float":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Avoid using `float`. Floats lead to fragile CSS that is easy to break if one aspect of the layout changes."},"scss.lint.idSelector":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Selectors should not contain IDs because these rules are too tightly coupled with the HTML."},"scss.lint.unknownAtRules":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Unknown at-rule."},"scss.format.enable":{"type":"boolean","scope":"window","default":true,"description":"Enable/disable default SCSS formatter."},"scss.format.newlineBetweenSelectors":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Separate selectors with a new line."},"scss.format.newlineBetweenRules":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Separate rulesets by a blank line."},"scss.format.spaceAroundSelectorSeparator":{"type":"boolean","scope":"resource","default":false,"markdownDescription":"Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`)."},"scss.format.braceStyle":{"type":"string","scope":"resource","default":"collapse","enum":["collapse","expand"],"markdownDescription":"Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`)."},"scss.format.preserveNewLines":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Whether existing line breaks before rules and declarations should be preserved."},"scss.format.maxPreserveNewLines":{"type":["number","null"],"scope":"resource","default":null,"markdownDescription":"Maximum number of line breaks to be preserved in one chunk, when `#scss.format.preserveNewLines#` is enabled."}}},{"id":"less","order":23,"type":"object","title":"LESS","properties":{"less.completion.triggerPropertyValueCompletion":{"type":"boolean","scope":"resource","default":true,"description":"By default, VS Code triggers property value completion after selecting a CSS property. Use this setting to disable this behavior."},"less.completion.completePropertyWithSemicolon":{"type":"boolean","scope":"resource","default":true,"description":"Insert semicolon at end of line when completing CSS properties."},"less.validate":{"type":"boolean","scope":"resource","default":true,"description":"Enables or disables all validations."},"less.hover.documentation":{"type":"boolean","scope":"resource","default":true,"description":"Show property and value documentation in LESS hovers."},"less.hover.references":{"type":"boolean","scope":"resource","default":true,"description":"Show references to MDN in LESS hovers."},"less.lint.compatibleVendorPrefixes":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"When using a vendor-specific prefix make sure to also include all other vendor-specific properties."},"less.lint.vendorPrefix":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"When using a vendor-specific prefix, also include the standard property."},"less.lint.duplicateProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Do not use duplicate style definitions."},"less.lint.emptyRules":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Do not use empty rulesets."},"less.lint.importStatement":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Import statements do not load in parallel."},"less.lint.boxModel":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Do not use `width` or `height` when using `padding` or `border`."},"less.lint.universalSelector":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"The universal selector (`*`) is known to be slow."},"less.lint.zeroUnits":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"No unit for zero needed."},"less.lint.fontFaceProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","markdownDescription":"`@font-face` rule must define `src` and `font-family` properties."},"less.lint.hexColorLength":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"error","description":"Hex colors must consist of 3, 4, 6 or 8 hex numbers."},"less.lint.argumentsInColorFunction":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"error","description":"Invalid number of parameters."},"less.lint.unknownProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Unknown property."},"less.lint.validProperties":{"type":"array","uniqueItems":true,"items":{"type":"string"},"scope":"resource","default":[],"description":"A list of properties that are not validated against the `unknownProperties` rule."},"less.lint.ieHack":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"IE hacks are only necessary when supporting IE7 and older."},"less.lint.unknownVendorSpecificProperties":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Unknown vendor specific property."},"less.lint.propertyIgnoredDueToDisplay":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","markdownDescription":"Property is ignored due to the display. E.g. with `display: inline`, the `width`, `height`, `margin-top`, `margin-bottom`, and `float` properties have no effect."},"less.lint.important":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Avoid using `!important`. It is an indication that the specificity of the entire CSS has gotten out of control and needs to be refactored."},"less.lint.float":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","markdownDescription":"Avoid using `float`. Floats lead to fragile CSS that is easy to break if one aspect of the layout changes."},"less.lint.idSelector":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"ignore","description":"Selectors should not contain IDs because these rules are too tightly coupled with the HTML."},"less.lint.unknownAtRules":{"type":"string","scope":"resource","enum":["ignore","warning","error"],"default":"warning","description":"Unknown at-rule."},"less.format.enable":{"type":"boolean","scope":"window","default":true,"description":"Enable/disable default LESS formatter."},"less.format.newlineBetweenSelectors":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Separate selectors with a new line."},"less.format.newlineBetweenRules":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Separate rulesets by a blank line."},"less.format.spaceAroundSelectorSeparator":{"type":"boolean","scope":"resource","default":false,"markdownDescription":"Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`)."},"less.format.braceStyle":{"type":"string","scope":"resource","default":"collapse","enum":["collapse","expand"],"markdownDescription":"Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`)."},"less.format.preserveNewLines":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Whether existing line breaks before rules and declarations should be preserved."},"less.format.maxPreserveNewLines":{"type":["number","null"],"scope":"resource","default":null,"markdownDescription":"Maximum number of line breaks to be preserved in one chunk, when `#less.format.preserveNewLines#` is enabled."}}}],"configurationDefaults":{"[css]":{"editor.suggest.insertMode":"replace"},"[scss]":{"editor.suggest.insertMode":"replace"},"[less]":{"editor.suggest.insertMode":"replace"}},"jsonValidation":[{"fileMatch":"*.css-data.json","url":"https://raw.githubusercontent.com/microsoft/vscode-css-languageservice/master/docs/customData.schema.json"},{"fileMatch":"package.json","url":"./schemas/package.schema.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/css-language-features","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.dart"},"manifest":{"name":"dart","displayName":"Dart Language Basics","description":"Provides syntax highlighting & bracket matching in Dart files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin dart-lang/dart-syntax-highlight grammars/dart.json ./syntaxes/dart.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"dart","extensions":[".dart"],"aliases":["Dart"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"dart","scopeName":"source.dart","path":"./syntaxes/dart.tmLanguage.json"}]}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/dart","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.debug-auto-launch"},"manifest":{"name":"debug-auto-launch","displayName":"Node Debug Auto-attach","description":"Helper for auto-attach feature when node-debug extensions are not active.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.5.0"},"icon":"media/icon.png","capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":true}},"activationEvents":["onStartupFinished"],"main":"./dist/extension","contributes":{"commands":[{"command":"extension.node-debug.toggleAutoAttach","title":"Toggle Auto Attach","category":"Debug"}]},"prettier":{"printWidth":100,"trailingComma":"all","singleQuote":true,"arrowParens":"avoid"},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/debug-auto-launch","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.debug-server-ready"},"manifest":{"name":"debug-server-ready","displayName":"Server Ready Action","description":"Open URI in browser if server under debugging is ready.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.32.0"},"icon":"media/icon.png","activationEvents":["onDebugResolve"],"capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":true}},"enabledApiProposals":["terminalDataWriteEvent"],"main":"./dist/extension","contributes":{"debuggers":[{"type":"*","configurationAttributes":{"launch":{"properties":{"serverReadyAction":{"oneOf":[{"type":"object","additionalProperties":false,"markdownDescription":"Act upon a URI when a server program under debugging is ready (indicated by sending output of the form 'listening on port 3000' or 'Now listening on: https://localhost:5001' to the debug console.)","default":{"action":"openExternally","killOnServerStop":false},"properties":{"action":{"type":"string","enum":["openExternally"],"enumDescriptions":["Open URI externally with the default application."],"markdownDescription":"What to do with the URI when the server is ready.","default":"openExternally"},"pattern":{"type":"string","markdownDescription":"Server is ready if this pattern appears on the debug console. The first capture group must include a URI or a port number.","default":"listening on port ([0-9]+)"},"uriFormat":{"type":"string","markdownDescription":"A format string used when constructing the URI from a port number. The first '%s' is substituted with the port number.","default":"http://localhost:%s"},"killOnServerStop":{"type":"boolean","markdownDescription":"Stop the child session when the parent session stopped.","default":false}}},{"type":"object","additionalProperties":false,"markdownDescription":"Act upon a URI when a server program under debugging is ready (indicated by sending output of the form 'listening on port 3000' or 'Now listening on: https://localhost:5001' to the debug console.)","default":{"action":"debugWithEdge","pattern":"listening on port ([0-9]+)","uriFormat":"http://localhost:%s","webRoot":"${workspaceFolder}","killOnServerStop":false},"properties":{"action":{"type":"string","enum":["debugWithChrome","debugWithEdge"],"enumDescriptions":["Start debugging with the 'Debugger for Chrome'."],"markdownDescription":"What to do with the URI when the server is ready.","default":"debugWithEdge"},"pattern":{"type":"string","markdownDescription":"Server is ready if this pattern appears on the debug console. The first capture group must include a URI or a port number.","default":"listening on port ([0-9]+)"},"uriFormat":{"type":"string","markdownDescription":"A format string used when constructing the URI from a port number. The first '%s' is substituted with the port number.","default":"http://localhost:%s"},"webRoot":{"type":"string","markdownDescription":"Value passed to the debug configuration for the 'Debugger for Chrome'.","default":"${workspaceFolder}"},"killOnServerStop":{"type":"boolean","markdownDescription":"Stop the child session when the parent session stopped.","default":false}}},{"type":"object","additionalProperties":false,"markdownDescription":"Act upon a URI when a server program under debugging is ready (indicated by sending output of the form 'listening on port 3000' or 'Now listening on: https://localhost:5001' to the debug console.)","default":{"action":"startDebugging","name":"","killOnServerStop":false},"required":["name"],"properties":{"action":{"type":"string","enum":["startDebugging"],"enumDescriptions":["Run another launch configuration."],"markdownDescription":"What to do with the URI when the server is ready.","default":"startDebugging"},"pattern":{"type":"string","markdownDescription":"Server is ready if this pattern appears on the debug console. The first capture group must include a URI or a port number.","default":"listening on port ([0-9]+)"},"name":{"type":"string","markdownDescription":"Name of the launch configuration to run.","default":"Launch Browser"},"killOnServerStop":{"type":"boolean","markdownDescription":"Stop the child session when the parent session stopped.","default":false}}},{"type":"object","additionalProperties":false,"markdownDescription":"Act upon a URI when a server program under debugging is ready (indicated by sending output of the form 'listening on port 3000' or 'Now listening on: https://localhost:5001' to the debug console.)","default":{"action":"startDebugging","config":{"type":"node","request":"launch"},"killOnServerStop":false},"required":["config"],"properties":{"action":{"type":"string","enum":["startDebugging"],"enumDescriptions":["Run another launch configuration."],"markdownDescription":"What to do with the URI when the server is ready.","default":"startDebugging"},"pattern":{"type":"string","markdownDescription":"Server is ready if this pattern appears on the debug console. The first capture group must include a URI or a port number.","default":"listening on port ([0-9]+)"},"config":{"type":"object","markdownDescription":"The debug configuration to run.","default":{}},"killOnServerStop":{"type":"boolean","markdownDescription":"Stop the child session when the parent session stopped.","default":false}}}]}}}}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/debug-server-ready","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.diff"},"manifest":{"name":"diff","displayName":"Diff Language Basics","description":"Provides syntax highlighting & bracket matching in Diff files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin textmate/diff.tmbundle Syntaxes/Diff.plist ./syntaxes/diff.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"diff","aliases":["Diff","diff"],"extensions":[".diff",".patch",".rej"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"diff","scopeName":"source.diff","path":"./syntaxes/diff.tmLanguage.json"}]}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/diff","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.docker"},"manifest":{"name":"docker","displayName":"Docker Language Basics","description":"Provides syntax highlighting and bracket matching in Docker files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"dockerfile","extensions":[".dockerfile",".containerfile"],"filenames":["Dockerfile","Containerfile"],"filenamePatterns":["Dockerfile.*","Containerfile.*"],"aliases":["Docker","Dockerfile","Containerfile"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"dockerfile","scopeName":"source.dockerfile","path":"./syntaxes/docker.tmLanguage.json"}],"configurationDefaults":{"[dockerfile]":{"editor.quickSuggestions":{"strings":true}}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/docker","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.dotenv"},"manifest":{"name":"dotenv","displayName":"Dotenv Language Basics","description":"Provides syntax highlighting and bracket matching in dotenv files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin dotenv-org/dotenv-vscode syntaxes/dotenv.tmLanguage.json ./syntaxes/dotenv.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"dotenv","extensions":[".env"],"filenames":[".env",".flaskenv","user-dirs.dirs"],"filenamePatterns":[".env.*"],"aliases":["Dotenv"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"dotenv","scopeName":"source.dotenv","path":"./syntaxes/dotenv.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/dotenv","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.emmet"},"manifest":{"name":"emmet","displayName":"Emmet","description":"Emmet support for VS Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.13.0"},"icon":"images/icon.png","categories":["Other"],"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"},"activationEvents":["onCommand:emmet.expandAbbreviation","onLanguage"],"main":"./dist/node/emmetNodeMain","browser":"./dist/browser/emmetBrowserMain","contributes":{"configuration":{"type":"object","title":"Emmet","properties":{"emmet.showExpandedAbbreviation":{"type":["string"],"enum":["never","always","inMarkupAndStylesheetFilesOnly"],"default":"always","markdownDescription":"Shows expanded Emmet abbreviations as suggestions.\nThe option `\"inMarkupAndStylesheetFilesOnly\"` applies to html, haml, jade, slim, xml, xsl, css, scss, sass, less and stylus.\nThe option `\"always\"` applies to all parts of the file regardless of markup/css."},"emmet.showAbbreviationSuggestions":{"type":"boolean","default":true,"scope":"language-overridable","markdownDescription":"Shows possible Emmet abbreviations as suggestions. Not applicable in stylesheets or when emmet.showExpandedAbbreviation is set to `\"never\"`."},"emmet.includeLanguages":{"type":"object","additionalProperties":{"type":"string"},"default":{},"markdownDescription":"Enable Emmet abbreviations in languages that are not supported by default. Add a mapping here between the language and Emmet supported language.\n For example: `{\"vue-html\": \"html\", \"javascript\": \"javascriptreact\"}`"},"emmet.variables":{"type":"object","properties":{"lang":{"type":"string","default":"en"},"charset":{"type":"string","default":"UTF-8"}},"additionalProperties":{"type":"string"},"default":{},"markdownDescription":"Variables to be used in Emmet snippets."},"emmet.syntaxProfiles":{"type":"object","default":{},"markdownDescription":"Define profile for specified syntax or use your own profile with specific rules."},"emmet.excludeLanguages":{"type":"array","items":{"type":"string"},"default":["markdown"],"markdownDescription":"An array of languages where Emmet abbreviations should not be expanded."},"emmet.extensionsPath":{"type":"array","items":{"type":"string","markdownDescription":"A path containing Emmet syntaxProfiles and/or snippets."},"default":[],"scope":"machine-overridable","markdownDescription":"An array of paths, where each path can contain Emmet syntaxProfiles and/or snippet files.\nIn case of conflicts, the profiles/snippets of later paths will override those of earlier paths.\nSee https://code.visualstudio.com/docs/editor/emmet for more information and an example snippet file."},"emmet.triggerExpansionOnTab":{"type":"boolean","default":false,"scope":"language-overridable","markdownDescription":"When enabled, Emmet abbreviations are expanded when pressing TAB, even when completions do not show up. When disabled, completions that show up can still be accepted by pressing TAB."},"emmet.useInlineCompletions":{"type":"boolean","default":false,"markdownDescription":"If `true`, Emmet will use inline completions to suggest expansions. To prevent the non-inline completion item provider from showing up as often while this setting is `true`, turn `#editor.quickSuggestions#` to `inline` or `off` for the `other` item."},"emmet.preferences":{"type":"object","default":{},"markdownDescription":"Preferences used to modify behavior of some actions and resolvers of Emmet.","properties":{"css.intUnit":{"type":"string","default":"px","markdownDescription":"Default unit for integer values."},"css.floatUnit":{"type":"string","default":"em","markdownDescription":"Default unit for float values."},"css.propertyEnd":{"type":"string","default":";","markdownDescription":"Symbol to be placed at the end of CSS property when expanding CSS abbreviations."},"sass.propertyEnd":{"type":"string","default":"","markdownDescription":"Symbol to be placed at the end of CSS property when expanding CSS abbreviations in Sass files."},"stylus.propertyEnd":{"type":"string","default":"","markdownDescription":"Symbol to be placed at the end of CSS property when expanding CSS abbreviations in Stylus files."},"css.valueSeparator":{"type":"string","default":": ","markdownDescription":"Symbol to be placed at the between CSS property and value when expanding CSS abbreviations."},"sass.valueSeparator":{"type":"string","default":": ","markdownDescription":"Symbol to be placed at the between CSS property and value when expanding CSS abbreviations in Sass files."},"stylus.valueSeparator":{"type":"string","default":" ","markdownDescription":"Symbol to be placed at the between CSS property and value when expanding CSS abbreviations in Stylus files."},"bem.elementSeparator":{"type":"string","default":"__","markdownDescription":"Element separator used for classes when using the BEM filter."},"bem.modifierSeparator":{"type":"string","default":"_","markdownDescription":"Modifier separator used for classes when using the BEM filter."},"filter.commentBefore":{"type":"string","default":"","markdownDescription":"A definition of comment that should be placed before matched element when comment filter is applied."},"filter.commentAfter":{"type":"string","default":"\n","markdownDescription":"A definition of comment that should be placed after matched element when comment filter is applied."},"filter.commentTrigger":{"type":"array","default":["id","class"],"markdownDescription":"A comma-separated list of attribute names that should exist in the abbreviation for the comment filter to be applied."},"format.noIndentTags":{"type":"array","default":["html"],"markdownDescription":"An array of tag names that should never get inner indentation."},"format.forceIndentationForTags":{"type":"array","default":["body"],"markdownDescription":"An array of tag names that should always get inner indentation."},"profile.allowCompactBoolean":{"type":"boolean","default":false,"markdownDescription":"If `true`, compact notation of boolean attributes are produced."},"css.webkitProperties":{"type":"string","default":null,"markdownDescription":"Comma separated CSS properties that get the 'webkit' vendor prefix when used in Emmet abbreviation that starts with `-`. Set to empty string to always avoid the 'webkit' prefix."},"css.mozProperties":{"type":"string","default":null,"markdownDescription":"Comma separated CSS properties that get the 'moz' vendor prefix when used in Emmet abbreviation that starts with `-`. Set to empty string to always avoid the 'moz' prefix."},"css.oProperties":{"type":"string","default":null,"markdownDescription":"Comma separated CSS properties that get the 'o' vendor prefix when used in Emmet abbreviation that starts with `-`. Set to empty string to always avoid the 'o' prefix."},"css.msProperties":{"type":"string","default":null,"markdownDescription":"Comma separated CSS properties that get the 'ms' vendor prefix when used in Emmet abbreviation that starts with `-`. Set to empty string to always avoid the 'ms' prefix."},"css.fuzzySearchMinScore":{"type":"number","default":0.3,"markdownDescription":"The minimum score (from 0 to 1) that fuzzy-matched abbreviation should achieve. Lower values may produce many false-positive matches, higher values may reduce possible matches."},"output.inlineBreak":{"type":"number","default":0,"markdownDescription":"The number of sibling inline elements needed for line breaks to be placed between those elements. If `0`, inline elements are always expanded onto a single line."},"output.reverseAttributes":{"type":"boolean","default":false,"markdownDescription":"If `true`, reverses attribute merging directions when resolving snippets."},"output.selfClosingStyle":{"type":"string","enum":["html","xhtml","xml"],"default":"html","markdownDescription":"Style of self-closing tags: html (`
`), xml (`
`) or xhtml (`
`)."},"css.color.short":{"type":"boolean","default":true,"markdownDescription":"If `true`, color values like `#f` will be expanded to `#fff` instead of `#ffffff`."}}},"emmet.showSuggestionsAsSnippets":{"type":"boolean","default":false,"markdownDescription":"If `true`, then Emmet suggestions will show up as snippets allowing you to order them as per `#editor.snippetSuggestions#` setting."},"emmet.optimizeStylesheetParsing":{"type":"boolean","default":true,"markdownDescription":"When set to `false`, the whole file is parsed to determine if current position is valid for expanding Emmet abbreviations. When set to `true`, only the content around the current position in CSS/SCSS/Less files is parsed."}}},"commands":[{"command":"editor.emmet.action.wrapWithAbbreviation","title":"Wrap with Abbreviation","category":"Emmet"},{"command":"editor.emmet.action.removeTag","title":"Remove Tag","category":"Emmet"},{"command":"editor.emmet.action.updateTag","title":"Update Tag","category":"Emmet"},{"command":"editor.emmet.action.matchTag","title":"Go to Matching Pair","category":"Emmet"},{"command":"editor.emmet.action.balanceIn","title":"Balance (inward)","category":"Emmet"},{"command":"editor.emmet.action.balanceOut","title":"Balance (outward)","category":"Emmet"},{"command":"editor.emmet.action.prevEditPoint","title":"Go to Previous Edit Point","category":"Emmet"},{"command":"editor.emmet.action.nextEditPoint","title":"Go to Next Edit Point","category":"Emmet"},{"command":"editor.emmet.action.mergeLines","title":"Merge Lines","category":"Emmet"},{"command":"editor.emmet.action.selectPrevItem","title":"Select Previous Item","category":"Emmet"},{"command":"editor.emmet.action.selectNextItem","title":"Select Next Item","category":"Emmet"},{"command":"editor.emmet.action.splitJoinTag","title":"Split/Join Tag","category":"Emmet"},{"command":"editor.emmet.action.toggleComment","title":"Toggle Comment","category":"Emmet"},{"command":"editor.emmet.action.evaluateMathExpression","title":"Evaluate Math Expression","category":"Emmet"},{"command":"editor.emmet.action.updateImageSize","title":"Update Image Size","category":"Emmet"},{"command":"editor.emmet.action.incrementNumberByOneTenth","title":"Increment by 0.1","category":"Emmet"},{"command":"editor.emmet.action.incrementNumberByOne","title":"Increment by 1","category":"Emmet"},{"command":"editor.emmet.action.incrementNumberByTen","title":"Increment by 10","category":"Emmet"},{"command":"editor.emmet.action.decrementNumberByOneTenth","title":"Decrement by 0.1","category":"Emmet"},{"command":"editor.emmet.action.decrementNumberByOne","title":"Decrement by 1","category":"Emmet"},{"command":"editor.emmet.action.decrementNumberByTen","title":"Decrement by 10","category":"Emmet"},{"command":"editor.emmet.action.reflectCSSValue","title":"Reflect CSS Value","category":"Emmet"},{"command":"workbench.action.showEmmetCommands","title":"Show Emmet Commands","category":""}],"menus":{"commandPalette":[{"command":"editor.emmet.action.wrapWithAbbreviation","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.removeTag","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.updateTag","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.matchTag","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.balanceIn","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.balanceOut","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.prevEditPoint","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.nextEditPoint","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.mergeLines","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.selectPrevItem","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.selectNextItem","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.splitJoinTag","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.toggleComment","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.evaluateMathExpression","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.updateImageSize","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.incrementNumberByOneTenth","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.incrementNumberByOne","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.incrementNumberByTen","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.decrementNumberByOneTenth","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.decrementNumberByOne","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.decrementNumberByTen","when":"!activeEditorIsReadonly"},{"command":"editor.emmet.action.reflectCSSValue","when":"!activeEditorIsReadonly"}]}},"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/emmet","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.extension-editing"},"manifest":{"name":"extension-editing","displayName":"Extension Authoring","description":"Provides linting capabilities for authoring extensions.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.4.0"},"icon":"images/icon.png","activationEvents":["onLanguage:json","onLanguage:markdown"],"main":"./dist/extensionEditingMain","browser":"./dist/browser/extensionEditingBrowserMain","capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"contributes":{"jsonValidation":[{"fileMatch":"package.json","url":"vscode://schemas/vscode-extensions"},{"fileMatch":"*language-configuration.json","url":"vscode://schemas/language-configuration"},{"fileMatch":["*icon-theme.json","!*product-icon-theme.json"],"url":"vscode://schemas/icon-theme"},{"fileMatch":"*product-icon-theme.json","url":"vscode://schemas/product-icon-theme"},{"fileMatch":"*color-theme.json","url":"vscode://schemas/color-theme"}],"languages":[{"id":"ignore","filenames":[".vscodeignore"]}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/extension-editing","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.fsharp"},"manifest":{"name":"fsharp","displayName":"F# Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in F# files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin ionide/ionide-fsgrammar grammars/fsharp.json ./syntaxes/fsharp.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"fsharp","extensions":[".fs",".fsi",".fsx",".fsscript"],"aliases":["F#","FSharp","fsharp"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"fsharp","scopeName":"source.fsharp","path":"./syntaxes/fsharp.tmLanguage.json"}],"snippets":[{"language":"fsharp","path":"./snippets/fsharp.code-snippets"}],"configurationDefaults":{"[fsharp]":{"diffEditor.ignoreTrimWhitespace":false}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/fsharp","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.git"},"manifest":{"name":"git","displayName":"Git","description":"Git SCM Integration","publisher":"vscode","license":"MIT","version":"1.0.0","engines":{"vscode":"^1.5.0"},"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","enabledApiProposals":["canonicalUriProvider","contribEditSessions","contribEditorContentMenu","contribMergeEditorMenus","contribMultiDiffEditorMenus","contribDiffEditorGutterToolBarMenus","contribSourceControlArtifactGroupMenu","contribSourceControlArtifactMenu","contribSourceControlHistoryItemMenu","contribSourceControlHistoryTitleMenu","contribSourceControlInputBoxMenu","contribSourceControlTitleMenu","contribViewsWelcome","editSessionIdentityProvider","findFiles2","quickDiffProvider","quickPickSortByLabel","scmActionButton","scmArtifactProvider","scmHistoryProvider","scmMultiDiffEditor","scmProviderOptions","scmSelectedProvider","scmTextDocument","scmValidation","statusBarItemTooltip","tabInputMultiDiff","tabInputTextMerge","textEditorDiffInformation","timeline","workspaceTrust"],"categories":["Other"],"activationEvents":["*","onEditSession:file","onFileSystem:git","onFileSystem:git-show"],"extensionDependencies":["vscode.git-base"],"main":"./dist/main","icon":"resources/icons/git.png","capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":false}},"contributes":{"commands":[{"command":"git.continueInLocalClone","title":"Clone Repository Locally and Open on Desktop...","category":"Git","icon":"$(repo-clone)","enablement":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && remoteName"},{"command":"git.clone","title":"Clone","category":"Git","enablement":"!operationInProgress"},{"command":"git.cloneRecursive","title":"Clone (Recursive)","category":"Git","enablement":"!operationInProgress"},{"command":"git.init","title":"Initialize Repository","category":"Git","icon":"$(add)","enablement":"!operationInProgress"},{"command":"git.openRepository","title":"Open Repository","category":"Git","enablement":"!operationInProgress"},{"command":"git.reopenClosedRepositories","title":"Reopen Closed Repositories...","icon":"$(repo)","category":"Git","enablement":"!operationInProgress && git.closedRepositoryCount != 0"},{"command":"git.close","title":"Close Repository","category":"Git","enablement":"!operationInProgress"},{"command":"git.closeOtherRepositories","title":"Close Other Repositories","category":"Git","enablement":"!operationInProgress"},{"command":"git.openWorktree","title":"Open Worktree in Current Window","category":"Git","enablement":"!operationInProgress"},{"command":"git.openWorktreeInNewWindow","title":"Open Worktree in New Window","category":"Git","enablement":"!operationInProgress"},{"command":"git.refresh","title":"Refresh","category":"Git","icon":"$(refresh)","enablement":"!operationInProgress"},{"command":"git.compareWithWorkspace","title":"Compare with Workspace","category":"Git"},{"command":"git.openChange","title":"Open Changes","category":"Git","icon":"$(compare-changes)"},{"command":"git.openAllChanges","title":"Open All Changes","category":"Git"},{"command":"git.openFile","title":"Open File","category":"Git","icon":"$(go-to-file)"},{"command":"git.openFile2","title":"Open File","category":"Git","icon":"$(go-to-file)"},{"command":"git.openHEADFile","title":"Open File (HEAD)","category":"Git"},{"command":"git.stage","title":"Stage Changes","category":"Git","icon":"$(add)","enablement":"!operationInProgress"},{"command":"git.stageAll","title":"Stage All Changes","category":"Git","icon":"$(add)","enablement":"!operationInProgress"},{"command":"git.stageAllTracked","title":"Stage All Tracked Changes","category":"Git","icon":"$(add)","enablement":"!operationInProgress"},{"command":"git.stageAllUntracked","title":"Stage All Untracked Changes","category":"Git","icon":"$(add)","enablement":"!operationInProgress"},{"command":"git.stageAllMerge","title":"Stage All Merge Changes","category":"Git","icon":"$(add)","enablement":"!operationInProgress"},{"command":"git.stageSelectedRanges","title":"Stage Selected Ranges","category":"Git","enablement":"!operationInProgress"},{"command":"git.diff.stageHunk","title":"Stage Block","category":"Git","icon":"$(plus)"},{"command":"git.diff.stageSelection","title":"Stage Selection","category":"Git","icon":"$(plus)"},{"command":"git.revertSelectedRanges","title":"Revert Selected Ranges","category":"Git","enablement":"!operationInProgress"},{"command":"git.stageChange","title":"Stage Change","category":"Git","icon":"$(add)","enablement":"!operationInProgress"},{"command":"git.stageFile","title":"Stage Changes","category":"Git","icon":"$(add)","enablement":"!operationInProgress"},{"command":"git.revertChange","title":"Revert Change","category":"Git","icon":"$(discard)","enablement":"!operationInProgress"},{"command":"git.unstage","title":"Unstage Changes","category":"Git","icon":"$(remove)","enablement":"!operationInProgress"},{"command":"git.unstageAll","title":"Unstage All Changes","category":"Git","icon":"$(remove)","enablement":"!operationInProgress"},{"command":"git.unstageSelectedRanges","title":"Unstage Selected Ranges","category":"Git","enablement":"!operationInProgress"},{"command":"git.unstageChange","title":"Unstage Change","category":"Git","icon":"$(remove)","enablement":"!operationInProgress"},{"command":"git.unstageFile","title":"Unstage Changes","category":"Git","icon":"$(remove)","enablement":"!operationInProgress"},{"command":"git.clean","title":"Discard Changes","category":"Git","icon":"$(discard)","enablement":"!operationInProgress"},{"command":"git.cleanAll","title":"Discard All Changes","category":"Git","icon":"$(discard)","enablement":"!operationInProgress"},{"command":"git.cleanAllTracked","title":"Discard All Tracked Changes","category":"Git","icon":"$(discard)","enablement":"!operationInProgress"},{"command":"git.cleanAllUntracked","title":"Discard All Untracked Changes","category":"Git","icon":"$(discard)","enablement":"!operationInProgress"},{"command":"git.rename","title":"Rename","category":"Git","icon":"$(discard)","enablement":"!operationInProgress"},{"command":"git.delete","title":"Delete","category":"Git","icon":"$(trash)","enablement":"!operationInProgress"},{"command":"git.commit","title":"Commit","category":"Git","icon":"$(check)","enablement":"!operationInProgress"},{"command":"git.commitAmend","title":"Commit (Amend)","category":"Git","icon":"$(check)","enablement":"!operationInProgress"},{"command":"git.commitSigned","title":"Commit (Signed Off)","category":"Git","icon":"$(check)","enablement":"!operationInProgress"},{"command":"git.commitStaged","title":"Commit Staged","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitEmpty","title":"Commit Empty","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitStagedSigned","title":"Commit Staged (Signed Off)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitStagedAmend","title":"Commit Staged (Amend)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitAll","title":"Commit All","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitAllSigned","title":"Commit All (Signed Off)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitAllAmend","title":"Commit All (Amend)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitNoVerify","title":"Commit (No Verify)","category":"Git","icon":"$(check)","enablement":"!operationInProgress"},{"command":"git.commitStagedNoVerify","title":"Commit Staged (No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitEmptyNoVerify","title":"Commit Empty (No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitStagedSignedNoVerify","title":"Commit Staged (Signed Off, No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitAmendNoVerify","title":"Commit (Amend, No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitSignedNoVerify","title":"Commit (Signed Off, No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitStagedAmendNoVerify","title":"Commit Staged (Amend, No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitAllNoVerify","title":"Commit All (No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitAllSignedNoVerify","title":"Commit All (Signed Off, No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitAllAmendNoVerify","title":"Commit All (Amend, No Verify)","category":"Git","enablement":"!operationInProgress"},{"command":"git.commitMessageAccept","title":"Commit","category":"Git"},{"command":"git.commitMessageDiscard","title":"Cancel","icon":"$(close)","category":"Git"},{"command":"git.restoreCommitTemplate","title":"Restore Commit Template","category":"Git","enablement":"!operationInProgress"},{"command":"git.undoCommit","title":"Undo Last Commit","category":"Git","enablement":"!operationInProgress"},{"command":"git.checkout","title":"Checkout to...","category":"Git","enablement":"!operationInProgress"},{"command":"git.graph.checkout","title":"Checkout","category":"Git","enablement":"!operationInProgress"},{"command":"git.checkoutDetached","title":"Checkout to (Detached)...","category":"Git","enablement":"!operationInProgress"},{"command":"git.graph.checkoutDetached","title":"Checkout (Detached)","category":"Git","enablement":"!operationInProgress"},{"command":"git.branch","title":"Create Branch...","category":"Git","enablement":"!operationInProgress"},{"command":"git.branchFrom","title":"Create Branch From...","category":"Git","enablement":"!operationInProgress"},{"command":"git.deleteBranch","title":"Delete Branch...","category":"Git","enablement":"!operationInProgress"},{"command":"git.graph.deleteBranch","title":"Delete Branch","category":"Git","enablement":"!operationInProgress"},{"command":"git.deleteRemoteBranch","title":"Delete Remote Branch...","category":"Git","enablement":"!operationInProgress"},{"command":"git.renameBranch","title":"Rename Branch...","category":"Git","enablement":"!operationInProgress"},{"command":"git.merge","title":"Merge...","category":"Git","enablement":"!operationInProgress"},{"command":"git.mergeAbort","title":"Abort Merge","category":"Git","enablement":"gitMergeInProgress"},{"command":"git.rebase","title":"Rebase Branch...","category":"Git","enablement":"!operationInProgress"},{"command":"git.createTag","title":"Create Tag...","icon":"$(plus)","category":"Git","enablement":"!operationInProgress"},{"command":"git.deleteTag","title":"Delete Tag...","category":"Git","enablement":"!operationInProgress"},{"command":"git.migrateWorktreeChanges","title":"Migrate Worktree Changes...","category":"Git","enablement":"!operationInProgress"},{"command":"git.createWorktree","title":"Create Worktree...","category":"Git","enablement":"!operationInProgress"},{"command":"git.deleteWorktree","title":"Delete Worktree...","category":"Git","enablement":"!operationInProgress"},{"command":"git.deleteWorktree2","title":"Delete Worktree","category":"Git","enablement":"!operationInProgress"},{"command":"git.graph.deleteTag","title":"Delete Tag","category":"Git","enablement":"!operationInProgress"},{"command":"git.deleteRemoteTag","title":"Delete Remote Tag...","category":"Git","enablement":"!operationInProgress"},{"command":"git.fetch","title":"Fetch","category":"Git","enablement":"!operationInProgress"},{"command":"git.fetchPrune","title":"Fetch (Prune)","category":"Git","enablement":"!operationInProgress"},{"command":"git.fetchAll","title":"Fetch From All Remotes","icon":"$(git-fetch)","category":"Git","enablement":"!operationInProgress"},{"command":"git.fetchRef","title":"Fetch","icon":"$(git-fetch)","category":"Git","enablement":"!operationInProgress"},{"command":"git.pull","title":"Pull","category":"Git","enablement":"!operationInProgress"},{"command":"git.pullRebase","title":"Pull (Rebase)","category":"Git","enablement":"!operationInProgress"},{"command":"git.pullFrom","title":"Pull from...","category":"Git","enablement":"!operationInProgress"},{"command":"git.pullRef","title":"Pull","icon":"$(repo-pull)","category":"Git","enablement":"!operationInProgress && scmCurrentHistoryItemRefInFilter && scmCurrentHistoryItemRefHasRemote"},{"command":"git.push","title":"Push","category":"Git","enablement":"!operationInProgress"},{"command":"git.pushForce","title":"Push (Force)","category":"Git","enablement":"!operationInProgress"},{"command":"git.pushTo","title":"Push to...","category":"Git","enablement":"!operationInProgress"},{"command":"git.pushToForce","title":"Push to... (Force)","category":"Git","enablement":"!operationInProgress"},{"command":"git.pushTags","title":"Push Tags","category":"Git","enablement":"!operationInProgress"},{"command":"git.pushWithTags","title":"Push (Follow Tags)","category":"Git","enablement":"!operationInProgress"},{"command":"git.pushWithTagsForce","title":"Push (Follow Tags, Force)","category":"Git","enablement":"!operationInProgress"},{"command":"git.pushRef","title":"Push","icon":"$(repo-push)","category":"Git","enablement":"!operationInProgress && scmCurrentHistoryItemRefInFilter && scmCurrentHistoryItemRefHasRemote"},{"command":"git.cherryPick","title":"Cherry Pick...","category":"Git","enablement":"!operationInProgress"},{"command":"git.graph.cherryPick","title":"Cherry Pick","category":"Git","enablement":"!operationInProgress"},{"command":"git.cherryPickAbort","title":"Abort Cherry Pick","category":"Git","enablement":"!operationInProgress"},{"command":"git.addRemote","title":"Add Remote...","category":"Git","enablement":"!operationInProgress"},{"command":"git.removeRemote","title":"Remove Remote","category":"Git","enablement":"!operationInProgress"},{"command":"git.sync","title":"Sync","category":"Git","enablement":"!operationInProgress"},{"command":"git.syncRebase","title":"Sync (Rebase)","category":"Git","enablement":"!operationInProgress"},{"command":"git.publish","title":"Publish Branch...","category":"Git","icon":"$(cloud-upload)","enablement":"!operationInProgress"},{"command":"git.showOutput","title":"Show Git Output","category":"Git"},{"command":"git.ignore","title":"Add to .gitignore","category":"Git","enablement":"!operationInProgress"},{"command":"git.revealInExplorer","title":"Reveal in Explorer View","category":"Git"},{"command":"git.revealFileInOS.linux","title":"Open Containing Folder","category":"Git"},{"command":"git.revealFileInOS.mac","title":"Reveal in Finder","category":"Git"},{"command":"git.revealFileInOS.windows","title":"Reveal in File Explorer","category":"Git"},{"command":"git.stashIncludeUntracked","title":"Stash (Include Untracked)","category":"Git","enablement":"!operationInProgress"},{"command":"git.stash","title":"Stash","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashStaged","title":"Stash Staged","category":"Git","enablement":"!operationInProgress && gitVersion2.35"},{"command":"git.stashPop","title":"Pop Stash...","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashPopLatest","title":"Pop Latest Stash","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashPopEditor","title":"Pop Stash","icon":"$(git-stash-pop)","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashApply","title":"Apply Stash...","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashApplyLatest","title":"Apply Latest Stash","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashApplyEditor","title":"Apply Stash","icon":"$(git-stash-apply)","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashDrop","title":"Drop Stash...","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashDropAll","title":"Drop All Stashes...","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashDropEditor","title":"Drop Stash","icon":"$(trash)","category":"Git","enablement":"!operationInProgress"},{"command":"git.stashView","title":"View Stash...","category":"Git","enablement":"!operationInProgress"},{"command":"git.timeline.openDiff","title":"Open Changes","icon":"$(compare-changes)","category":"Git"},{"command":"git.timeline.copyCommitId","title":"Copy Commit ID","category":"Git"},{"command":"git.timeline.copyCommitMessage","title":"Copy Commit Message","category":"Git"},{"command":"git.timeline.selectForCompare","title":"Select for Compare","category":"Git"},{"command":"git.timeline.compareWithSelected","title":"Compare with Selected","category":"Git"},{"command":"git.timeline.viewCommit","title":"Open Commit","icon":"$(diff-multiple)","category":"Git"},{"command":"git.rebaseAbort","title":"Abort Rebase","category":"Git","enablement":"gitRebaseInProgress"},{"command":"git.closeAllDiffEditors","title":"Close All Diff Editors","category":"Git","enablement":"!operationInProgress"},{"command":"git.closeAllUnmodifiedEditors","title":"Close All Unmodified Editors","category":"Git","enablement":"!operationInProgress"},{"command":"git.api.getRepositories","title":"Get Repositories","category":"Git API"},{"command":"git.api.getRepositoryState","title":"Get Repository State","category":"Git API"},{"command":"git.api.getRemoteSources","title":"Get Remote Sources","category":"Git API"},{"command":"git.acceptMerge","title":"Complete Merge","category":"Git","enablement":"isMergeEditor && mergeEditorResultUri in git.mergeChanges"},{"command":"git.openMergeEditor","title":"Resolve in Merge Editor","category":"Git"},{"command":"git.runGitMerge","title":"Compute Conflicts With Git","category":"Git","enablement":"isMergeEditor"},{"command":"git.runGitMergeDiff3","title":"Compute Conflicts With Git (Diff3)","category":"Git","enablement":"isMergeEditor"},{"command":"git.manageUnsafeRepositories","title":"Manage Unsafe Repositories","category":"Git"},{"command":"git.openRepositoriesInParentFolders","title":"Open Repositories In Parent Folders","category":"Git"},{"command":"git.viewChanges","title":"Open Changes","icon":"$(diff-multiple)","category":"Git","enablement":"!operationInProgress"},{"command":"git.viewStagedChanges","title":"Open Staged Changes","icon":"$(diff-multiple)","category":"Git","enablement":"!operationInProgress"},{"command":"git.viewUntrackedChanges","title":"Open Untracked Changes","icon":"$(diff-multiple)","category":"Git","enablement":"!operationInProgress"},{"command":"git.viewCommit","title":"Open Commit","icon":"$(diff-multiple)","category":"Git","enablement":"!operationInProgress"},{"command":"git.copyCommitId","title":"Copy Commit ID","category":"Git"},{"command":"git.copyCommitMessage","title":"Copy Commit Message","category":"Git"},{"command":"git.blame.toggleEditorDecoration","title":"Toggle Git Blame Editor Decoration","category":"Git"},{"command":"git.blame.toggleStatusBarItem","title":"Toggle Git Blame Status Bar Item","category":"Git"},{"command":"git.graph.compareRef","title":"Compare with...","category":"Git","enablement":"!operationInProgress"},{"command":"git.graph.compareWithRemote","title":"Compare with Remote","category":"Git","enablement":"!operationInProgress && scmCurrentHistoryItemRefHasRemote"},{"command":"git.graph.compareWithMergeBase","title":"Compare with Merge Base","category":"Git","enablement":"!operationInProgress && scmCurrentHistoryItemRefHasBase"},{"command":"git.repositories.checkout","title":"Checkout","icon":"$(target)","category":"Git","enablement":"!operationInProgress && !scmArtifactIsHistoryItemRef"},{"command":"git.repositories.checkoutDetached","title":"Checkout (Detached)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.compareRef","title":"Compare with...","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.createBranch","title":"Create Branch...","icon":"$(plus)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.createTag","title":"Create Tag...","icon":"$(plus)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.merge","title":"Merge","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.rebase","title":"Rebase","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.deleteBranch","title":"Delete","category":"Git","enablement":"!operationInProgress && !scmArtifactIsHistoryItemRef"},{"command":"git.repositories.deleteTag","title":"Delete","category":"Git","enablement":"!operationInProgress && !scmArtifactIsHistoryItemRef"},{"command":"git.repositories.createFrom","title":"Create from...","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.stashView","title":"View Stash","icon":"$(diff-multiple)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.stashApply","title":"Apply Stash","icon":"$(git-stash-apply)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.stashPop","title":"Pop Stash","icon":"$(git-stash-pop)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.stashDrop","title":"Drop Stash","icon":"$(trash)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.createWorktree","title":"Create Worktree...","icon":"$(plus)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.openWorktree","title":"Open","icon":"$(folder-opened)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.openWorktreeInNewWindow","title":"Open in New Window","icon":"$(folder-opened)","category":"Git","enablement":"!operationInProgress"},{"command":"git.repositories.deleteWorktree","title":"Delete","category":"Git","enablement":"!operationInProgress"}],"continueEditSession":[{"command":"git.continueInLocalClone","qualifiedName":"Continue Working in New Local Clone","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && remoteName","remoteGroup":"remote_42_git_0_local@0"}],"keybindings":[{"command":"git.stageSelectedRanges","key":"ctrl+k ctrl+alt+s","mac":"cmd+k cmd+alt+s","when":"editorTextFocus && resourceScheme == file"},{"command":"git.unstageSelectedRanges","key":"ctrl+k ctrl+n","mac":"cmd+k cmd+n","when":"editorTextFocus && isInDiffEditor && isInDiffRightEditor && resourceScheme == git"},{"command":"git.revertSelectedRanges","key":"ctrl+k ctrl+r","mac":"cmd+k cmd+r","when":"editorTextFocus && resourceScheme == file"}],"menus":{"commandPalette":[{"command":"git.continueInLocalClone","when":"false"},{"command":"git.clone","when":"config.git.enabled && !git.missing"},{"command":"git.cloneRecursive","when":"config.git.enabled && !git.missing"},{"command":"git.init","when":"config.git.enabled && !git.missing && remoteName != 'codespaces'"},{"command":"git.openRepository","when":"config.git.enabled && !git.missing"},{"command":"git.close","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.closeOtherRepositories","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount > 1"},{"command":"git.openWorktree","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount > 1"},{"command":"git.openWorktreeInNewWindow","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount > 1"},{"command":"git.refresh","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.openFile","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == file && scmActiveResourceHasChanges"},{"command":"git.openHEADFile","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == file && scmActiveResourceHasChanges"},{"command":"git.openChange","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stage","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stageAll","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stageAllTracked","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stageAllUntracked","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stageAllMerge","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stageSelectedRanges","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == file"},{"command":"git.stageChange","when":"false"},{"command":"git.revertSelectedRanges","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == file"},{"command":"git.revertChange","when":"false"},{"command":"git.openFile2","when":"false"},{"command":"git.unstage","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.unstageAll","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.unstageSelectedRanges","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == git"},{"command":"git.unstageChange","when":"false"},{"command":"git.clean","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.cleanAll","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.cleanAllTracked","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.cleanAllUntracked","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.rename","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == file && scmActiveResourceRepository"},{"command":"git.delete","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == file"},{"command":"git.commit","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitAmend","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitSigned","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitStaged","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitEmpty","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitStagedSigned","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitStagedAmend","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitAll","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitAllSigned","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.commitAllAmend","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.rebaseAbort","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && gitRebaseInProgress"},{"command":"git.commitNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitStagedNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitEmptyNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitStagedSignedNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitAmendNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitSignedNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitStagedAmendNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitAllNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitAllSignedNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.commitAllAmendNoVerify","when":"config.git.enabled && !git.missing && config.git.allowNoVerifyCommit && gitOpenRepositoryCount != 0"},{"command":"git.restoreCommitTemplate","when":"false"},{"command":"git.commitMessageAccept","when":"false"},{"command":"git.commitMessageDiscard","when":"false"},{"command":"git.revealInExplorer","when":"false"},{"command":"git.revealFileInOS.linux","when":"false"},{"command":"git.revealFileInOS.mac","when":"false"},{"command":"git.revealFileInOS.windows","when":"false"},{"command":"git.undoCommit","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.checkout","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.branch","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.branchFrom","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.deleteBranch","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.deleteRemoteBranch","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.renameBranch","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.cherryPick","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.cherryPickAbort","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && gitCherryPickInProgress"},{"command":"git.pull","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.pullFrom","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.pullRebase","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.merge","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.mergeAbort","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && gitMergeInProgress"},{"command":"git.rebase","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.createTag","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.deleteTag","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.migrateWorktreeChanges","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.createWorktree","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.openWorktree","when":"false"},{"command":"git.openWorktreeInNewWindow","when":"false"},{"command":"git.deleteWorktree","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.deleteWorktree2","when":"false"},{"command":"git.deleteRemoteTag","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.fetch","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.fetchPrune","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.fetchAll","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.push","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.pushForce","when":"config.git.enabled && !git.missing && config.git.allowForcePush && gitOpenRepositoryCount != 0"},{"command":"git.pushTo","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.pushToForce","when":"config.git.enabled && !git.missing && config.git.allowForcePush && gitOpenRepositoryCount != 0"},{"command":"git.pushWithTags","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.pushWithTagsForce","when":"config.git.enabled && !git.missing && config.git.allowForcePush && gitOpenRepositoryCount != 0"},{"command":"git.pushTags","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.addRemote","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.removeRemote","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.sync","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.syncRebase","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.publish","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.showOutput","when":"config.git.enabled"},{"command":"git.ignore","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && resourceScheme == file && scmActiveResourceRepository"},{"command":"git.stashIncludeUntracked","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stash","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stashStaged","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && gitVersion2.35"},{"command":"git.stashPop","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stashPopLatest","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stashPopEditor","when":"false"},{"command":"git.stashApply","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stashApplyLatest","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stashApplyEditor","when":"false"},{"command":"git.stashDrop","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stashDropAll","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.stashDropEditor","when":"false"},{"command":"git.timeline.openDiff","when":"false"},{"command":"git.timeline.copyCommitId","when":"false"},{"command":"git.timeline.copyCommitMessage","when":"false"},{"command":"git.timeline.selectForCompare","when":"false"},{"command":"git.timeline.compareWithSelected","when":"false"},{"command":"git.timeline.viewCommit","when":"false"},{"command":"git.closeAllDiffEditors","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"},{"command":"git.api.getRepositories","when":"false"},{"command":"git.api.getRepositoryState","when":"false"},{"command":"git.api.getRemoteSources","when":"false"},{"command":"git.openMergeEditor","when":"false"},{"command":"git.manageUnsafeRepositories","when":"config.git.enabled && !git.missing && git.unsafeRepositoryCount != 0"},{"command":"git.openRepositoriesInParentFolders","when":"config.git.enabled && !git.missing && git.parentRepositoryCount != 0"},{"command":"git.stashView","when":"config.git.enabled && !git.missing"},{"command":"git.viewChanges","when":"config.git.enabled && !git.missing"},{"command":"git.viewStagedChanges","when":"config.git.enabled && !git.missing"},{"command":"git.viewUntrackedChanges","when":"config.git.enabled && !git.missing && config.git.untrackedChanges == separate"},{"command":"git.viewCommit","when":"false"},{"command":"git.stageFile","when":"false"},{"command":"git.unstageFile","when":"false"},{"command":"git.fetchRef","when":"false"},{"command":"git.pullRef","when":"false"},{"command":"git.pushRef","when":"false"},{"command":"git.copyCommitId","when":"false"},{"command":"git.copyCommitMessage","when":"false"},{"command":"git.graph.checkout","when":"false"},{"command":"git.graph.checkoutDetached","when":"false"},{"command":"git.graph.deleteBranch","when":"false"},{"command":"git.graph.compareRef","when":"false"},{"command":"git.graph.deleteTag","when":"false"},{"command":"git.graph.cherryPick","when":"false"},{"command":"git.graph.compareWithMergeBase","when":"false"},{"command":"git.graph.compareWithRemote","when":"false"},{"command":"git.diff.stageHunk","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && diffEditorOriginalUri =~ /^git\\:.*%22ref%22%3A%22~%22%7D$/"},{"command":"git.diff.stageSelection","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && diffEditorOriginalUri =~ /^git\\:.*%22ref%22%3A%22~%22%7D$/"},{"command":"git.repositories.checkout","when":"false"},{"command":"git.repositories.checkoutDetached","when":"false"},{"command":"git.repositories.compareRef","when":"false"},{"command":"git.repositories.createBranch","when":"false"},{"command":"git.repositories.createTag","when":"false"},{"command":"git.repositories.merge","when":"false"},{"command":"git.repositories.rebase","when":"false"},{"command":"git.repositories.deleteBranch","when":"false"},{"command":"git.repositories.deleteTag","when":"false"},{"command":"git.repositories.createFrom","when":"false"},{"command":"git.repositories.stashView","when":"false"},{"command":"git.repositories.stashApply","when":"false"},{"command":"git.repositories.stashPop","when":"false"},{"command":"git.repositories.stashDrop","when":"false"},{"command":"git.repositories.createWorktree","when":"false"},{"command":"git.repositories.openWorktree","when":"false"},{"command":"git.repositories.openWorktreeInNewWindow","when":"false"},{"command":"git.repositories.deleteWorktree","when":"false"}],"scm/title":[{"command":"git.commit","group":"navigation","when":"scmProvider == git"},{"command":"git.refresh","group":"navigation","when":"scmProvider == git"},{"command":"git.pull","group":"1_header@1","when":"scmProvider == git"},{"command":"git.push","group":"1_header@2","when":"scmProvider == git"},{"command":"git.clone","group":"1_header@3","when":"scmProvider == git"},{"command":"git.checkout","group":"1_header@4","when":"scmProvider == git"},{"command":"git.fetch","group":"1_header@5","when":"scmProvider == git"},{"submenu":"git.commit","group":"2_main@1","when":"scmProvider == git"},{"submenu":"git.changes","group":"2_main@2","when":"scmProvider == git"},{"submenu":"git.pullpush","group":"2_main@3","when":"scmProvider == git"},{"submenu":"git.branch","group":"2_main@4","when":"scmProvider == git"},{"submenu":"git.remotes","group":"2_main@5","when":"scmProvider == git"},{"submenu":"git.stash","group":"2_main@6","when":"scmProvider == git"},{"submenu":"git.tags","group":"2_main@7","when":"scmProvider == git"},{"submenu":"git.worktrees","group":"2_main@8","when":"scmProvider == git"},{"command":"git.showOutput","group":"3_footer","when":"scmProvider == git"}],"scm/repositories/title":[{"command":"git.reopenClosedRepositories","group":"navigation@1","when":"git.closedRepositoryCount > 0"}],"scm/repository":[{"command":"git.pull","group":"1_header@1","when":"scmProvider == git"},{"command":"git.push","group":"1_header@2","when":"scmProvider == git"},{"command":"git.clone","group":"1_header@3","when":"scmProvider == git"},{"command":"git.checkout","group":"1_header@4","when":"scmProvider == git"},{"command":"git.fetch","group":"1_header@5","when":"scmProvider == git"},{"submenu":"git.commit","group":"2_main@1","when":"scmProvider == git"},{"submenu":"git.changes","group":"2_main@2","when":"scmProvider == git"},{"submenu":"git.pullpush","group":"2_main@3","when":"scmProvider == git"},{"submenu":"git.branch","group":"2_main@4","when":"scmProvider == git"},{"submenu":"git.remotes","group":"2_main@5","when":"scmProvider == git"},{"submenu":"git.stash","group":"2_main@6","when":"scmProvider == git"},{"submenu":"git.tags","group":"2_main@7","when":"scmProvider == git"},{"submenu":"git.worktrees","group":"2_main@8","when":"scmProvider == git"},{"command":"git.showOutput","group":"3_footer","when":"scmProvider == git"}],"scm/sourceControl":[{"command":"git.close","group":"navigation@1","when":"scmProvider == git"},{"command":"git.closeOtherRepositories","group":"navigation@2","when":"scmProvider == git && gitOpenRepositoryCount > 1"},{"command":"git.openWorktree","group":"1_worktree@1","when":"scmProvider == git && scmProviderContext == worktree"},{"command":"git.openWorktreeInNewWindow","group":"1_worktree@2","when":"scmProvider == git && scmProviderContext == worktree"},{"command":"git.deleteWorktree2","group":"2_worktree@1","when":"scmProvider == git && scmProviderContext == worktree"}],"scm/artifactGroup/context":[{"command":"git.repositories.createBranch","group":"inline@1","when":"scmProvider == git && scmArtifactGroup == branches"},{"command":"git.repositories.createTag","group":"inline@1","when":"scmProvider == git && scmArtifactGroup == tags"},{"submenu":"git.repositories.stash","group":"inline@1","when":"scmProvider == git && scmArtifactGroup == stashes"},{"command":"git.repositories.createWorktree","group":"inline@1","when":"scmProvider == git && scmArtifactGroup == worktrees"}],"scm/artifact/context":[{"command":"git.repositories.checkout","group":"inline@1","when":"scmProvider == git && (scmArtifactGroupId == branches || scmArtifactGroupId == tags)"},{"command":"git.repositories.stashApply","alt":"git.repositories.stashPop","group":"inline@1","when":"scmProvider == git && scmArtifactGroupId == stashes"},{"command":"git.repositories.stashView","group":"1_view@1","when":"scmProvider == git && scmArtifactGroupId == stashes"},{"command":"git.repositories.stashApply","group":"2_apply@1","when":"scmProvider == git && scmArtifactGroupId == stashes"},{"command":"git.repositories.stashPop","group":"2_apply@2","when":"scmProvider == git && scmArtifactGroupId == stashes"},{"command":"git.repositories.stashDrop","group":"3_drop@3","when":"scmProvider == git && scmArtifactGroupId == stashes"},{"command":"git.repositories.checkout","group":"1_checkout@1","when":"scmProvider == git && (scmArtifactGroupId == branches || scmArtifactGroupId == tags)"},{"command":"git.repositories.checkoutDetached","group":"1_checkout@2","when":"scmProvider == git && (scmArtifactGroupId == branches || scmArtifactGroupId == tags)"},{"command":"git.repositories.merge","group":"2_modify@1","when":"scmProvider == git && scmArtifactGroupId == branches"},{"command":"git.repositories.rebase","group":"2_modify@2","when":"scmProvider == git && scmArtifactGroupId == branches"},{"command":"git.repositories.createFrom","group":"3_modify@1","when":"scmProvider == git && scmArtifactGroupId == branches"},{"command":"git.repositories.deleteBranch","group":"3_modify@2","when":"scmProvider == git && scmArtifactGroupId == branches"},{"command":"git.repositories.deleteTag","group":"3_modify@1","when":"scmProvider == git && scmArtifactGroupId == tags"},{"command":"git.repositories.compareRef","group":"4_compare@1","when":"scmProvider == git && (scmArtifactGroupId == branches || scmArtifactGroupId == tags)"},{"command":"git.repositories.openWorktreeInNewWindow","group":"inline@1","when":"scmProvider == git && scmArtifactGroupId == worktrees"},{"command":"git.repositories.openWorktree","group":"1_open@1","when":"scmProvider == git && scmArtifactGroupId == worktrees"},{"command":"git.repositories.openWorktreeInNewWindow","group":"1_open@2","when":"scmProvider == git && scmArtifactGroupId == worktrees"},{"command":"git.repositories.deleteWorktree","group":"2_modify@1","when":"scmProvider == git && scmArtifactGroupId == worktrees"}],"scm/resourceGroup/context":[{"command":"git.stageAllMerge","when":"scmProvider == git && scmResourceGroup == merge","group":"1_modification"},{"command":"git.stageAllMerge","when":"scmProvider == git && scmResourceGroup == merge","group":"inline@2"},{"command":"git.unstageAll","when":"scmProvider == git && scmResourceGroup == index","group":"1_modification"},{"command":"git.unstageAll","when":"scmProvider == git && scmResourceGroup == index","group":"inline@2"},{"command":"git.viewStagedChanges","when":"scmProvider == git && scmResourceGroup == index","group":"inline@1"},{"command":"git.viewChanges","when":"scmProvider == git && scmResourceGroup == workingTree","group":"inline@1"},{"command":"git.cleanAll","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.untrackedChanges == mixed","group":"1_modification"},{"command":"git.stageAll","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.untrackedChanges == mixed","group":"1_modification"},{"command":"git.cleanAll","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.untrackedChanges == mixed","group":"inline@2"},{"command":"git.stageAll","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.untrackedChanges == mixed","group":"inline@2"},{"command":"git.cleanAllTracked","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.untrackedChanges != mixed","group":"1_modification"},{"command":"git.stageAllTracked","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.untrackedChanges != mixed","group":"1_modification"},{"command":"git.cleanAllTracked","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.untrackedChanges != mixed","group":"inline@2"},{"command":"git.stageAllTracked","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.untrackedChanges != mixed","group":"inline@2"},{"command":"git.cleanAllUntracked","when":"scmProvider == git && scmResourceGroup == untracked","group":"1_modification"},{"command":"git.stageAllUntracked","when":"scmProvider == git && scmResourceGroup == untracked","group":"1_modification"},{"command":"git.viewUntrackedChanges","when":"scmProvider == git && scmResourceGroup == untracked","group":"inline@1"},{"command":"git.cleanAllUntracked","when":"scmProvider == git && scmResourceGroup == untracked","group":"inline@2"},{"command":"git.stageAllUntracked","when":"scmProvider == git && scmResourceGroup == untracked","group":"inline@2"}],"scm/resourceFolder/context":[{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == merge","group":"1_modification"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == merge","group":"inline@2"},{"command":"git.unstage","when":"scmProvider == git && scmResourceGroup == index","group":"1_modification"},{"command":"git.unstage","when":"scmProvider == git && scmResourceGroup == index","group":"inline@2"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == workingTree","group":"1_modification"},{"command":"git.clean","when":"scmProvider == git && scmResourceGroup == workingTree","group":"1_modification"},{"command":"git.clean","when":"scmProvider == git && scmResourceGroup == workingTree","group":"inline@2"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == workingTree","group":"inline@2"},{"command":"git.ignore","when":"scmProvider == git && scmResourceGroup == workingTree","group":"1_modification@3"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == untracked","group":"1_modification"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == untracked","group":"inline@2"},{"command":"git.clean","when":"scmProvider == git && scmResourceGroup == untracked","group":"1_modification"},{"command":"git.clean","when":"scmProvider == git && scmResourceGroup == untracked","group":"inline@2"},{"command":"git.ignore","when":"scmProvider == git && scmResourceGroup == untracked","group":"1_modification@3"}],"scm/resourceState/context":[{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == merge","group":"1_modification"},{"command":"git.openFile","when":"scmProvider == git && scmResourceGroup == merge","group":"navigation"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == merge","group":"inline@2"},{"command":"git.revealFileInOS.linux","when":"scmProvider == git && scmResourceGroup == merge && remoteName == '' && isLinux","group":"2_view@1"},{"command":"git.revealFileInOS.mac","when":"scmProvider == git && scmResourceGroup == merge && remoteName == '' && isMac","group":"2_view@1"},{"command":"git.revealFileInOS.windows","when":"scmProvider == git && scmResourceGroup == merge && remoteName == '' && isWindows","group":"2_view@1"},{"command":"git.revealInExplorer","when":"scmProvider == git && scmResourceGroup == merge","group":"2_view@2"},{"command":"git.openFile2","when":"scmProvider == git && scmResourceGroup == merge && config.git.showInlineOpenFileAction && config.git.openDiffOnClick","group":"inline@1"},{"command":"git.openChange","when":"scmProvider == git && scmResourceGroup == merge && config.git.showInlineOpenFileAction && !config.git.openDiffOnClick","group":"inline@1"},{"command":"git.openChange","when":"scmProvider == git && scmResourceGroup == index","group":"navigation"},{"command":"git.openFile","when":"scmProvider == git && scmResourceGroup == index","group":"navigation"},{"command":"git.openHEADFile","when":"scmProvider == git && scmResourceGroup == index","group":"navigation"},{"command":"git.unstage","when":"scmProvider == git && scmResourceGroup == index","group":"1_modification"},{"command":"git.unstage","when":"scmProvider == git && scmResourceGroup == index","group":"inline@2"},{"command":"git.revealFileInOS.linux","when":"scmProvider == git && scmResourceGroup == index && remoteName == '' && isLinux","group":"2_view@1"},{"command":"git.revealFileInOS.mac","when":"scmProvider == git && scmResourceGroup == index && remoteName == '' && isMac","group":"2_view@1"},{"command":"git.revealFileInOS.windows","when":"scmProvider == git && scmResourceGroup == index && remoteName == '' && isWindows","group":"2_view@1"},{"command":"git.revealInExplorer","when":"scmProvider == git && scmResourceGroup == index","group":"2_view@2"},{"command":"git.compareWithWorkspace","when":"scmProvider == git && scmResourceGroup == index && scmResourceState == worktree","group":"worktree_diff"},{"command":"git.openFile2","when":"scmProvider == git && scmResourceGroup == index && config.git.showInlineOpenFileAction && config.git.openDiffOnClick","group":"inline@1"},{"command":"git.openChange","when":"scmProvider == git && scmResourceGroup == index && config.git.showInlineOpenFileAction && !config.git.openDiffOnClick","group":"inline@1"},{"command":"git.openChange","when":"scmProvider == git && scmResourceGroup == workingTree","group":"navigation"},{"command":"git.openHEADFile","when":"scmProvider == git && scmResourceGroup == workingTree","group":"navigation"},{"command":"git.openFile","when":"scmProvider == git && scmResourceGroup == workingTree","group":"navigation"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == workingTree","group":"1_modification"},{"command":"git.clean","when":"scmProvider == git && scmResourceGroup == workingTree","group":"1_modification"},{"command":"git.clean","when":"scmProvider == git && scmResourceGroup == workingTree","group":"inline@2"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == workingTree","group":"inline@2"},{"command":"git.compareWithWorkspace","when":"scmProvider == git && scmResourceGroup == workingTree && scmResourceState == worktree","group":"worktree_diff"},{"command":"git.openFile2","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.showInlineOpenFileAction && config.git.openDiffOnClick","group":"inline@1"},{"command":"git.openChange","when":"scmProvider == git && scmResourceGroup == workingTree && config.git.showInlineOpenFileAction && !config.git.openDiffOnClick","group":"inline@1"},{"command":"git.ignore","when":"scmProvider == git && scmResourceGroup == workingTree","group":"1_modification@3"},{"command":"git.revealFileInOS.linux","when":"scmProvider == git && scmResourceGroup == workingTree && remoteName == '' && isLinux","group":"2_view@1"},{"command":"git.revealFileInOS.mac","when":"scmProvider == git && scmResourceGroup == workingTree && remoteName == '' && isMac","group":"2_view@1"},{"command":"git.revealFileInOS.windows","when":"scmProvider == git && scmResourceGroup == workingTree && remoteName == '' && isWindows","group":"2_view@1"},{"command":"git.revealInExplorer","when":"scmProvider == git && scmResourceGroup == workingTree","group":"2_view@2"},{"command":"git.openChange","when":"scmProvider == git && scmResourceGroup == untracked","group":"navigation"},{"command":"git.openHEADFile","when":"scmProvider == git && scmResourceGroup == untracked","group":"navigation"},{"command":"git.openFile","when":"scmProvider == git && scmResourceGroup == untracked","group":"navigation"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == untracked","group":"1_modification"},{"command":"git.clean","when":"scmProvider == git && scmResourceGroup == untracked && !gitFreshRepository","group":"1_modification"},{"command":"git.clean","when":"scmProvider == git && scmResourceGroup == untracked && !gitFreshRepository","group":"inline@2"},{"command":"git.stage","when":"scmProvider == git && scmResourceGroup == untracked","group":"inline@2"},{"command":"git.openFile2","when":"scmProvider == git && scmResourceGroup == untracked && config.git.showInlineOpenFileAction && config.git.openDiffOnClick","group":"inline@1"},{"command":"git.openChange","when":"scmProvider == git && scmResourceGroup == untracked && config.git.showInlineOpenFileAction && !config.git.openDiffOnClick","group":"inline@1"},{"command":"git.ignore","when":"scmProvider == git && scmResourceGroup == untracked","group":"1_modification@3"}],"scm/history/title":[{"command":"git.fetchAll","group":"navigation@900","when":"scmProvider == git"},{"command":"git.pullRef","group":"navigation@901","when":"scmProvider == git"},{"command":"git.pushRef","when":"scmProvider == git && scmCurrentHistoryItemRefHasRemote","group":"navigation@902"},{"command":"git.publish","when":"scmProvider == git && !scmCurrentHistoryItemRefHasRemote","group":"navigation@903"}],"scm/historyItem/context":[{"command":"git.graph.checkoutDetached","when":"scmProvider == git","group":"1_checkout@2"},{"command":"git.branch","when":"scmProvider == git","group":"2_branch@2"},{"command":"git.createTag","when":"scmProvider == git","group":"3_tag@1"},{"command":"git.graph.cherryPick","when":"scmProvider == git","group":"4_modify@1"},{"command":"git.graph.compareWithRemote","when":"scmProvider == git","group":"5_compare@1"},{"command":"git.graph.compareWithMergeBase","when":"scmProvider == git","group":"5_compare@2"},{"command":"git.graph.compareRef","when":"scmProvider == git","group":"5_compare@3"},{"command":"git.copyCommitId","when":"scmProvider == git && !listMultiSelection","group":"9_copy@1"},{"command":"git.copyCommitMessage","when":"scmProvider == git && !listMultiSelection","group":"9_copy@2"}],"scm/historyItemRef/context":[{"command":"git.graph.checkout","when":"scmProvider == git","group":"1_checkout@1"},{"command":"git.graph.deleteBranch","when":"scmProvider == git && scmHistoryItemRef =~ /^refs\\/heads\\/|^refs\\/remotes\\//","group":"2_branch@2"},{"command":"git.graph.deleteTag","when":"scmProvider == git && scmHistoryItemRef =~ /^refs\\/tags\\//","group":"3_tag@2"}],"editor/title":[{"command":"git.openFile","group":"navigation","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && resourceScheme =~ /^git$|^file$/"},{"command":"git.openFile","group":"navigation","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInNotebookTextDiffEditor && resourceScheme =~ /^git$|^file$/"},{"command":"git.openFile","group":"navigation","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isInNotebookTextDiffEditor && resourceScheme == git"},{"command":"git.openChange","group":"navigation@2","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && scmActiveResourceHasChanges"},{"command":"git.stashApplyEditor","alt":"git.stashPopEditor","group":"navigation@1","when":"config.git.enabled && !git.missing && resourceScheme == git-stash"},{"command":"git.stashDropEditor","group":"navigation@2","when":"config.git.enabled && !git.missing && resourceScheme == git-stash"},{"command":"git.stage","group":"2_git@1","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasUnstagedChanges"},{"command":"git.unstage","group":"2_git@2","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasStagedChanges"},{"command":"git.stage","group":"2_git@1","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file"},{"command":"git.stageSelectedRanges","group":"2_git@2","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file"},{"command":"git.unstage","group":"2_git@3","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git"},{"command":"git.unstageSelectedRanges","group":"2_git@4","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git"},{"command":"git.revertSelectedRanges","group":"2_git@5","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file"}],"editor/context":[{"command":"git.stageSelectedRanges","group":"2_git@1","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file"},{"command":"git.unstageSelectedRanges","group":"2_git@2","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git"},{"command":"git.revertSelectedRanges","group":"2_git@3","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file"}],"editor/content":[{"command":"git.acceptMerge","when":"isMergeResultEditor && mergeEditorBaseUri =~ /^(git|file):/ && mergeEditorResultUri in git.mergeChanges"},{"command":"git.openMergeEditor","group":"navigation@-10","when":"config.git.enabled && !git.missing && !isInDiffEditor && !isMergeEditor && resource in git.mergeChanges && git.activeResourceHasMergeConflicts"},{"command":"git.commitMessageAccept","group":"navigation","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && editorLangId == git-commit"},{"command":"git.commitMessageDiscard","group":"secondary","when":"config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && editorLangId == git-commit"}],"multiDiffEditor/resource/title":[{"command":"git.stageFile","group":"navigation","when":"scmProvider == git && scmResourceGroup == workingTree"},{"command":"git.stageFile","group":"navigation","when":"scmProvider == git && scmResourceGroup == untracked"},{"command":"git.unstageFile","group":"navigation","when":"scmProvider == git && scmResourceGroup == index"}],"diffEditor/gutter/hunk":[{"command":"git.diff.stageHunk","group":"primary@10","when":"diffEditorOriginalUri =~ /^git\\:.*%22ref%22%3A%22~%22%7D$/"}],"diffEditor/gutter/selection":[{"command":"git.diff.stageSelection","group":"primary@10","when":"diffEditorOriginalUri =~ /^git\\:.*%22ref%22%3A%22~%22%7D$/"}],"scm/change/title":[{"command":"git.stageChange","when":"config.git.enabled && !git.missing && originalResource =~ /^git\\:.*%22ref%22%3A%22%22%7D$/"},{"command":"git.revertChange","when":"config.git.enabled && !git.missing && originalResource =~ /^git\\:.*%22ref%22%3A%22%22%7D$/"},{"command":"git.unstageChange","when":"false"}],"timeline/item/context":[{"command":"git.timeline.viewCommit","group":"inline","when":"config.git.enabled && !git.missing && timelineItem =~ /git:file:commit\\b/ && !listMultiSelection"},{"command":"git.timeline.openDiff","group":"1_actions@1","when":"config.git.enabled && !git.missing && timelineItem =~ /git:file\\b/ && !listMultiSelection"},{"command":"git.timeline.viewCommit","group":"1_actions@2","when":"config.git.enabled && !git.missing && timelineItem =~ /git:file:commit\\b/ && !listMultiSelection"},{"command":"git.timeline.compareWithSelected","group":"3_compare@1","when":"config.git.enabled && !git.missing && git.timeline.selectedForCompare && timelineItem =~ /git:file\\b/ && !listMultiSelection"},{"command":"git.timeline.selectForCompare","group":"3_compare@2","when":"config.git.enabled && !git.missing && timelineItem =~ /git:file\\b/ && !listMultiSelection"},{"command":"git.timeline.copyCommitId","group":"5_copy@1","when":"config.git.enabled && !git.missing && timelineItem =~ /git:file:commit\\b/ && !listMultiSelection"},{"command":"git.timeline.copyCommitMessage","group":"5_copy@2","when":"config.git.enabled && !git.missing && timelineItem =~ /git:file:commit\\b/ && !listMultiSelection"}],"git.commit":[{"command":"git.commit","group":"1_commit@1"},{"command":"git.commitStaged","group":"1_commit@2"},{"command":"git.commitAll","group":"1_commit@3"},{"command":"git.undoCommit","group":"1_commit@4"},{"command":"git.rebaseAbort","group":"1_commit@5"},{"command":"git.commitNoVerify","group":"2_commit_noverify@1","when":"config.git.allowNoVerifyCommit"},{"command":"git.commitStagedNoVerify","group":"2_commit_noverify@2","when":"config.git.allowNoVerifyCommit"},{"command":"git.commitAllNoVerify","group":"2_commit_noverify@3","when":"config.git.allowNoVerifyCommit"},{"command":"git.commitAmend","group":"3_amend@1"},{"command":"git.commitStagedAmend","group":"3_amend@2"},{"command":"git.commitAllAmend","group":"3_amend@3"},{"command":"git.commitAmendNoVerify","group":"4_amend_noverify@1","when":"config.git.allowNoVerifyCommit"},{"command":"git.commitStagedAmendNoVerify","group":"4_amend_noverify@2","when":"config.git.allowNoVerifyCommit"},{"command":"git.commitAllAmendNoVerify","group":"4_amend_noverify@3","when":"config.git.allowNoVerifyCommit"},{"command":"git.commitSigned","group":"5_signoff@1"},{"command":"git.commitStagedSigned","group":"5_signoff@2"},{"command":"git.commitAllSigned","group":"5_signoff@3"},{"command":"git.commitSignedNoVerify","group":"6_signoff_noverify@1","when":"config.git.allowNoVerifyCommit"},{"command":"git.commitStagedSignedNoVerify","group":"6_signoff_noverify@2","when":"config.git.allowNoVerifyCommit"},{"command":"git.commitAllSignedNoVerify","group":"6_signoff_noverify@3","when":"config.git.allowNoVerifyCommit"}],"git.changes":[{"command":"git.stageAll","group":"changes@1"},{"command":"git.unstageAll","group":"changes@2"},{"command":"git.cleanAll","group":"changes@3"}],"git.pullpush":[{"command":"git.sync","group":"1_sync@1"},{"command":"git.syncRebase","when":"gitState == idle","group":"1_sync@2"},{"command":"git.pull","group":"2_pull@1"},{"command":"git.pullRebase","group":"2_pull@2"},{"command":"git.pullFrom","group":"2_pull@3"},{"command":"git.push","group":"3_push@1"},{"command":"git.pushForce","when":"config.git.allowForcePush","group":"3_push@2"},{"command":"git.pushTo","group":"3_push@3"},{"command":"git.pushToForce","when":"config.git.allowForcePush","group":"3_push@4"},{"command":"git.fetch","group":"4_fetch@1"},{"command":"git.fetchPrune","group":"4_fetch@2"},{"command":"git.fetchAll","group":"4_fetch@3"}],"git.branch":[{"command":"git.merge","group":"1_merge@1"},{"command":"git.rebase","group":"1_merge@2"},{"command":"git.branch","group":"2_branch@1"},{"command":"git.branchFrom","group":"2_branch@2"},{"command":"git.renameBranch","group":"3_modify@1"},{"command":"git.deleteBranch","group":"3_modify@2"},{"command":"git.deleteRemoteBranch","group":"3_modify@3"},{"command":"git.publish","group":"4_publish@1"}],"git.remotes":[{"command":"git.addRemote","group":"remote@1"},{"command":"git.removeRemote","group":"remote@2"}],"git.stash":[{"command":"git.stash","group":"1_stash@1"},{"command":"git.stashIncludeUntracked","group":"1_stash@2"},{"command":"git.stashStaged","when":"gitVersion2.35","group":"1_stash@3"},{"command":"git.stashApplyLatest","group":"2_apply@1"},{"command":"git.stashApply","group":"2_apply@2"},{"command":"git.stashPopLatest","group":"3_pop@1"},{"command":"git.stashPop","group":"3_pop@2"},{"command":"git.stashDrop","group":"4_drop@1"},{"command":"git.stashDropAll","group":"4_drop@2"},{"command":"git.stashView","group":"5_preview@1"}],"git.repositories.stash":[{"command":"git.stash","group":"1_stash@1"},{"command":"git.stashStaged","when":"gitVersion2.35","group":"2_stash@1"},{"command":"git.stashIncludeUntracked","group":"2_stash@2"}],"git.tags":[{"command":"git.createTag","group":"1_tags@1"},{"command":"git.deleteTag","group":"1_tags@2"},{"command":"git.deleteRemoteTag","group":"1_tags@3"},{"command":"git.pushTags","group":"2_tags@1"}],"git.worktrees":[{"when":"scmProviderContext == worktree","command":"git.openWorktree","group":"openWorktrees@1"},{"when":"scmProviderContext == worktree","command":"git.openWorktreeInNewWindow","group":"openWorktrees@2"},{"when":"scmProviderContext == repository","command":"git.createWorktree","group":"worktrees@1"},{"when":"scmProviderContext == worktree","command":"git.deleteWorktree2","group":"worktrees@2"}]},"submenus":[{"id":"git.commit","label":"Commit"},{"id":"git.changes","label":"Changes"},{"id":"git.pullpush","label":"Pull, Push"},{"id":"git.branch","label":"Branch"},{"id":"git.remotes","label":"Remote"},{"id":"git.stash","label":"Stash"},{"id":"git.tags","label":"Tags"},{"id":"git.worktrees","label":"Worktrees"},{"id":"git.repositories.stash","label":"Stash","icon":"$(plus)"}],"configuration":{"title":"Git","properties":{"git.enabled":{"type":"boolean","scope":"resource","description":"Whether Git is enabled.","default":true},"git.path":{"type":["string","null","array"],"markdownDescription":"Path and filename of the git executable, e.g. `C:\\Program Files\\Git\\bin\\git.exe` (Windows). This can also be an array of string values containing multiple paths to look up.","default":null,"scope":"machine"},"git.autoRepositoryDetection":{"type":["boolean","string"],"enum":[true,false,"subFolders","openEditors"],"enumDescriptions":["Scan for both subfolders of the current opened folder and parent folders of open files.","Disable automatic repository scanning.","Scan for subfolders of the currently opened folder.","Scan for parent folders of open files."],"description":"Configures when repositories should be automatically detected.","default":true},"git.autorefresh":{"type":"boolean","description":"Whether auto refreshing is enabled.","default":true},"git.autofetch":{"type":["boolean","string"],"enum":[true,false,"all"],"scope":"resource","markdownDescription":"When set to true, commits will automatically be fetched from the default remote of the current Git repository. Setting to `all` will fetch from all remotes.","default":false,"tags":["usesOnlineServices"]},"git.autofetchPeriod":{"type":"number","scope":"resource","markdownDescription":"Duration in seconds between each automatic git fetch, when `#git.autofetch#` is enabled.","default":180},"git.defaultBranchName":{"type":"string","markdownDescription":"The name of the default branch (example: main, trunk, development) when initializing a new Git repository. When set to empty, the default branch name configured in Git will be used. **Note:** Requires Git version `2.28.0` or later.","default":"main","scope":"resource"},"git.branchPrefix":{"type":"string","description":"Prefix used when creating a new branch.","default":"","scope":"resource"},"git.branchProtection":{"type":"array","markdownDescription":"List of protected branches. By default, a prompt is shown before changes are committed to a protected branch. The prompt can be controlled using the `#git.branchProtectionPrompt#` setting.","items":{"type":"string"},"default":[],"scope":"resource"},"git.branchProtectionPrompt":{"type":"string","description":"Controls whether a prompt is being shown before changes are committed to a protected branch.","enum":["alwaysCommit","alwaysCommitToNewBranch","alwaysPrompt"],"enumDescriptions":["Always commit changes to the protected branch.","Always commit changes to a new branch.","Always prompt before changes are committed to a protected branch."],"default":"alwaysPrompt","scope":"resource"},"git.branchValidationRegex":{"type":"string","description":"A regular expression to validate new branch names.","default":""},"git.branchWhitespaceChar":{"type":"string","description":"The character to replace whitespace in new branch names, and to separate segments of a randomly generated branch name.","default":"-"},"git.branchRandomName.enable":{"type":"boolean","description":"Controls whether a random name is generated when creating a new branch.","default":false,"scope":"resource"},"git.branchRandomName.dictionary":{"type":"array","markdownDescription":"List of dictionaries used for the randomly generated branch name. Each value represents the dictionary used to generate the segment of the branch name. Supported dictionaries: `adjectives`, `animals`, `colors` and `numbers`.","items":{"type":"string","enum":["adjectives","animals","colors","numbers"],"enumDescriptions":["A random adjective","A random animal name","A random color name","A random number between 100 and 999"]},"minItems":1,"maxItems":5,"default":["adjectives","animals"],"scope":"resource"},"git.confirmSync":{"type":"boolean","description":"Confirm before synchronizing Git repositories.","default":true},"git.confirmCommittedDelete":{"type":"boolean","description":"Confirm before deleting committed files with Git.","default":true},"git.countBadge":{"type":"string","enum":["all","tracked","off"],"enumDescriptions":["Count all changes.","Count only tracked changes.","Turn off counter."],"description":"Controls the Git count badge.","default":"all","scope":"resource"},"git.checkoutType":{"type":"array","items":{"type":"string","enum":["local","tags","remote"],"enumDescriptions":["Local branches","Tags","Remote branches"]},"uniqueItems":true,"markdownDescription":"Controls what type of Git refs are listed when running `Checkout to...`.","default":["local","remote","tags"]},"git.ignoreLegacyWarning":{"type":"boolean","description":"Ignores the legacy Git warning.","default":false},"git.ignoreMissingGitWarning":{"type":"boolean","description":"Ignores the warning when Git is missing.","default":false},"git.ignoreWindowsGit27Warning":{"type":"boolean","description":"Ignores the warning when Git 2.25 - 2.26 is installed on Windows.","default":false},"git.ignoreLimitWarning":{"type":"boolean","description":"Ignores the warning when there are too many changes in a repository.","default":false},"git.ignoreRebaseWarning":{"type":"boolean","description":"Ignores the warning when it looks like the branch might have been rebased when pulling.","default":false},"git.defaultCloneDirectory":{"type":["string","null"],"default":null,"scope":"machine","description":"The default location to clone a Git repository."},"git.useEditorAsCommitInput":{"type":"boolean","description":"Controls whether a full text editor will be used to author commit messages, whenever no message is provided in the commit input box.","default":true},"git.verboseCommit":{"type":"boolean","scope":"resource","markdownDescription":"Enable verbose output when `#git.useEditorAsCommitInput#` is enabled.","default":false},"git.enableSmartCommit":{"type":"boolean","scope":"resource","description":"Commit all changes when there are no staged changes.","default":false},"git.smartCommitChanges":{"type":"string","enum":["all","tracked"],"enumDescriptions":["Automatically stage all changes.","Automatically stage tracked changes only."],"scope":"resource","description":"Control which changes are automatically staged by Smart Commit.","default":"all"},"git.suggestSmartCommit":{"type":"boolean","scope":"resource","description":"Suggests to enable smart commit (commit all changes when there are no staged changes).","default":true},"git.enableCommitSigning":{"type":"boolean","scope":"resource","description":"Enables commit signing with GPG, X.509, or SSH.","default":false},"git.confirmEmptyCommits":{"type":"boolean","scope":"resource","description":"Always confirm the creation of empty commits for the 'Git: Commit Empty' command.","default":true},"git.decorations.enabled":{"type":"boolean","default":true,"description":"Controls whether Git contributes colors and badges to the Explorer and the Open Editors view."},"git.enableStatusBarSync":{"type":"boolean","default":true,"description":"Controls whether the Git Sync command appears in the status bar.","scope":"resource"},"git.followTagsWhenSync":{"type":"boolean","scope":"resource","default":false,"description":"Push all annotated tags when running the sync command."},"git.replaceTagsWhenPull":{"type":"boolean","scope":"resource","default":false,"description":"Automatically replace the local tags with the remote tags in case of a conflict when running the pull command."},"git.promptToSaveFilesBeforeStash":{"type":"string","enum":["always","staged","never"],"enumDescriptions":["Check for any unsaved files.","Check only for unsaved staged files.","Disable this check."],"scope":"resource","default":"always","description":"Controls whether Git should check for unsaved files before stashing changes."},"git.promptToSaveFilesBeforeCommit":{"type":"string","enum":["always","staged","never"],"enumDescriptions":["Check for any unsaved files.","Check only for unsaved staged files.","Disable this check."],"scope":"resource","default":"always","description":"Controls whether Git should check for unsaved files before committing."},"git.postCommitCommand":{"type":"string","enum":["none","push","sync"],"enumDescriptions":["Don't run any command after a commit.","Run 'git push' after a successful commit.","Run 'git pull' and 'git push' after a successful commit."],"markdownDescription":"Run a git command after a successful commit.","scope":"resource","default":"none"},"git.rememberPostCommitCommand":{"type":"boolean","description":"Remember the last git command that ran after a commit.","scope":"resource","default":false},"git.openAfterClone":{"type":"string","enum":["always","alwaysNewWindow","whenNoFolderOpen","prompt"],"enumDescriptions":["Always open in current window.","Always open in a new window.","Only open in current window when no folder is opened.","Always prompt for action."],"default":"prompt","description":"Controls whether to open a repository automatically after cloning."},"git.showInlineOpenFileAction":{"type":"boolean","default":true,"description":"Controls whether to show an inline Open File action in the Git changes view."},"git.showPushSuccessNotification":{"type":"boolean","description":"Controls whether to show a notification when a push is successful.","default":false},"git.inputValidation":{"type":"boolean","default":false,"description":"Controls whether to show commit message input validation diagnostics."},"git.inputValidationLength":{"type":"number","default":72,"description":"Controls the commit message length threshold for showing a warning."},"git.inputValidationSubjectLength":{"type":["number","null"],"default":50,"markdownDescription":"Controls the commit message subject length threshold for showing a warning. Unset it to inherit the value of `#git.inputValidationLength#`."},"git.detectSubmodules":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether to automatically detect Git submodules."},"git.detectSubmodulesLimit":{"type":"number","scope":"resource","default":10,"description":"Controls the limit of Git submodules detected."},"git.detectWorktrees":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether to automatically detect Git worktrees."},"git.detectWorktreesLimit":{"type":"number","scope":"resource","default":50,"description":"Controls the limit of Git worktrees detected."},"git.worktreeIncludeFiles":{"type":"array","items":{"type":"string"},"default":[],"markdownDescription":"Configure [glob patterns](https://aka.ms/vscode-glob-patterns) for files and folders that are included when creating a new worktree. Only files and folders that match the patterns and are listed in `.gitignore` will be copied to the newly created worktree.","scope":"resource","tags":["experimental"]},"git.alwaysShowStagedChangesResourceGroup":{"type":"boolean","scope":"resource","default":false,"description":"Always show the Staged Changes resource group."},"git.alwaysSignOff":{"type":"boolean","scope":"resource","default":false,"description":"Controls the signoff flag for all commits."},"git.ignoreSubmodules":{"type":"boolean","scope":"resource","default":false,"description":"Ignore modifications to submodules in the file tree."},"git.ignoredRepositories":{"type":"array","items":{"type":"string"},"default":[],"scope":"window","description":"List of Git repositories to ignore."},"git.scanRepositories":{"type":"array","items":{"type":"string"},"default":[],"scope":"resource","description":"List of paths to search for Git repositories in."},"git.showProgress":{"type":"boolean","description":"Controls whether Git actions should show progress.","default":true,"scope":"resource"},"git.rebaseWhenSync":{"type":"boolean","scope":"resource","default":false,"description":"Force Git to use rebase when running the sync command."},"git.pullBeforeCheckout":{"type":"boolean","scope":"resource","default":false,"description":"Controls whether a branch that does not have outgoing commits is fast-forwarded before it is checked out."},"git.fetchOnPull":{"type":"boolean","scope":"resource","default":false,"description":"When enabled, fetch all branches when pulling. Otherwise, fetch just the current one."},"git.pruneOnFetch":{"type":"boolean","scope":"resource","default":false,"description":"Prune when fetching."},"git.pullTags":{"type":"boolean","scope":"resource","default":true,"description":"Fetch all tags when pulling."},"git.autoStash":{"type":"boolean","scope":"resource","default":false,"description":"Stash any changes before pulling and restore them after successful pull."},"git.allowForcePush":{"type":"boolean","default":false,"description":"Controls whether force push (with or without lease) is enabled."},"git.useForcePushWithLease":{"type":"boolean","default":true,"description":"Controls whether force pushing uses the safer force-with-lease variant."},"git.useForcePushIfIncludes":{"type":"boolean","default":true,"markdownDescription":"Controls whether force pushing uses the safer force-if-includes variant. Note: This setting requires the `#git.useForcePushWithLease#` setting to be enabled, and Git version `2.30.0` or later."},"git.confirmForcePush":{"type":"boolean","default":true,"description":"Controls whether to ask for confirmation before force-pushing."},"git.allowNoVerifyCommit":{"type":"boolean","default":false,"description":"Controls whether commits without running pre-commit and commit-msg hooks are allowed."},"git.confirmNoVerifyCommit":{"type":"boolean","default":true,"description":"Controls whether to ask for confirmation before committing without verification."},"git.closeDiffOnOperation":{"type":"boolean","scope":"resource","default":false,"description":"Controls whether the diff editor should be automatically closed when changes are stashed, committed, discarded, staged, or unstaged."},"git.openDiffOnClick":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether the diff editor should be opened when clicking a change. Otherwise the regular editor will be opened."},"git.supportCancellation":{"type":"boolean","scope":"resource","default":false,"description":"Controls whether a notification comes up when running the Sync action, which allows the user to cancel the operation."},"git.branchSortOrder":{"type":"string","enum":["committerdate","alphabetically"],"default":"committerdate","description":"Controls the sort order for branches."},"git.untrackedChanges":{"type":"string","enum":["mixed","separate","hidden"],"enumDescriptions":["All changes, tracked and untracked, appear together and behave equally.","Untracked changes appear separately in the Source Control view. They are also excluded from several actions.","Untracked changes are hidden and excluded from several actions."],"default":"mixed","description":"Controls how untracked changes behave.","scope":"resource"},"git.requireGitUserConfig":{"type":"boolean","description":"Controls whether to require explicit Git user configuration or allow Git to guess if missing.","default":true,"scope":"resource"},"git.showCommitInput":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether to show the commit input in the Git source control panel."},"git.terminalAuthentication":{"type":"boolean","default":true,"description":"Controls whether to enable VS Code to be the authentication handler for Git processes spawned in the Integrated Terminal. Note: Terminals need to be restarted to pick up a change in this setting."},"git.terminalGitEditor":{"type":"boolean","default":false,"description":"Controls whether to enable VS Code to be the Git editor for Git processes spawned in the integrated terminal. Note: Terminals need to be restarted to pick up a change in this setting."},"git.useCommitInputAsStashMessage":{"type":"boolean","scope":"resource","default":false,"description":"Controls whether to use the message from the commit input box as the default stash message."},"git.useIntegratedAskPass":{"type":"boolean","default":true,"description":"Controls whether GIT_ASKPASS should be overwritten to use the integrated version."},"git.githubAuthentication":{"markdownDeprecationMessage":"This setting is now deprecated, please use `#github.gitAuthentication#` instead."},"git.timeline.date":{"type":"string","enum":["committed","authored"],"enumDescriptions":["Use the committed date","Use the authored date"],"default":"committed","description":"Controls which date to use for items in the Timeline view.","scope":"window"},"git.timeline.showAuthor":{"type":"boolean","default":true,"description":"Controls whether to show the commit author in the Timeline view.","scope":"window"},"git.timeline.showUncommitted":{"type":"boolean","default":false,"description":"Controls whether to show uncommitted changes in the Timeline view.","scope":"window"},"git.showActionButton":{"type":"object","additionalProperties":false,"description":"Controls whether an action button is shown in the Source Control view.","properties":{"commit":{"type":"boolean","description":"Show an action button to commit changes when the local branch has modified files ready to be committed."},"publish":{"type":"boolean","description":"Show an action button to publish the local branch when it does not have a tracking remote branch."},"sync":{"type":"boolean","description":"Show an action button to synchronize changes when the local branch is either ahead or behind the remote branch."}},"default":{"commit":true,"publish":true,"sync":true},"scope":"resource"},"git.statusLimit":{"type":"number","scope":"resource","default":10000,"description":"Controls how to limit the number of changes that can be parsed from Git status command. Can be set to 0 for no limit."},"git.repositoryScanIgnoredFolders":{"type":"array","items":{"type":"string"},"default":["node_modules"],"scope":"resource","markdownDescription":"List of folders that are ignored while scanning for Git repositories when `#git.autoRepositoryDetection#` is set to `true` or `subFolders`."},"git.repositoryScanMaxDepth":{"type":"number","scope":"resource","default":1,"markdownDescription":"Controls the depth used when scanning workspace folders for Git repositories when `#git.autoRepositoryDetection#` is set to `true` or `subFolders`. Can be set to `-1` for no limit."},"git.commandsToLog":{"type":"array","items":{"type":"string"},"default":[],"markdownDescription":"List of git commands (ex: commit, push) that would have their `stdout` logged to the [git output](command:git.showOutput). If the git command has a client-side hook configured, the client-side hook's `stdout` will also be logged to the [git output](command:git.showOutput)."},"git.mergeEditor":{"type":"boolean","default":false,"markdownDescription":"Open the merge editor for files that are currently under conflict.","scope":"window"},"git.optimisticUpdate":{"type":"boolean","default":true,"markdownDescription":"Controls whether to optimistically update the state of the Source Control view after running git commands.","scope":"resource","tags":["experimental"]},"git.openRepositoryInParentFolders":{"type":"string","enum":["always","never","prompt"],"enumDescriptions":["Always open a repository in parent folders of workspaces or open files.","Never open a repository in parent folders of workspaces or open files.","Prompt before opening a repository the parent folders of workspaces or open files."],"default":"prompt","markdownDescription":"Control whether a repository in parent folders of workspaces or open files should be opened.","scope":"resource"},"git.similarityThreshold":{"type":"number","default":50,"minimum":0,"maximum":100,"markdownDescription":"Controls the threshold of the similarity index (the amount of additions/deletions compared to the file's size) for changes in a pair of added/deleted files to be considered a rename. **Note:** Requires Git version `2.18.0` or later.","scope":"resource"},"git.blame.editorDecoration.enabled":{"type":"boolean","default":false,"markdownDescription":"Controls whether to show blame information in the editor using editor decorations."},"git.blame.editorDecoration.template":{"type":"string","default":"${subject}, ${authorName} (${authorDateAgo})","markdownDescription":"Template for the blame information editor decoration. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First N characters of the commit hash according to `#git.commitShortHashLength#`\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n"},"git.blame.editorDecoration.disableHover":{"type":"boolean","default":false,"markdownDescription":"Controls whether to disable the blame information editor decoration hover."},"git.blame.statusBarItem.enabled":{"type":"boolean","default":true,"markdownDescription":"Controls whether to show blame information in the status bar."},"git.blame.statusBarItem.template":{"type":"string","default":"${authorName} (${authorDateAgo})","markdownDescription":"Template for the blame information status bar item. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First N characters of the commit hash according to `#git.commitShortHashLength#`\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n"},"git.blame.ignoreWhitespace":{"type":"boolean","default":false,"markdownDescription":"Controls whether to ignore whitespace changes when computing blame information."},"git.commitShortHashLength":{"type":"number","default":7,"minimum":7,"maximum":40,"markdownDescription":"Controls the length of the commit short hash.","scope":"resource"},"git.diagnosticsCommitHook.enabled":{"type":"boolean","default":false,"markdownDescription":"Controls whether to check for unresolved diagnostics before committing.","scope":"resource"},"git.diagnosticsCommitHook.sources":{"type":"object","additionalProperties":{"type":"string","enum":["error","warning","information","hint","none"]},"default":{"*":"error"},"markdownDescription":"Controls the list of sources (**Item**) and the minimum severity (**Value**) to be considered before committing. **Note:** To ignore diagnostics from a particular source, add the source to the list and set the minimum severity to `none`.","scope":"resource"},"git.discardUntrackedChangesToTrash":{"type":"boolean","default":true,"markdownDescription":"Controls whether discarding untracked changes moves the file(s) to the Recycle Bin (Windows), Trash (macOS, Linux) instead of deleting them permanently. **Note:** This setting has no effect when connected to a remote or when running in Linux as a snap package."},"git.showReferenceDetails":{"type":"boolean","default":true,"markdownDescription":"Controls whether to show the details of the last commit for Git refs in the checkout, branch, and tag pickers."}}},"colors":[{"id":"gitDecoration.addedResourceForeground","description":"Color for added resources.","defaults":{"light":"#587c0c","dark":"#81b88b","highContrast":"#a1e3ad","highContrastLight":"#374e06"}},{"id":"gitDecoration.modifiedResourceForeground","description":"Color for modified resources.","defaults":{"light":"#895503","dark":"#E2C08D","highContrast":"#E2C08D","highContrastLight":"#895503"}},{"id":"gitDecoration.deletedResourceForeground","description":"Color for deleted resources.","defaults":{"light":"#ad0707","dark":"#c74e39","highContrast":"#c74e39","highContrastLight":"#ad0707"}},{"id":"gitDecoration.renamedResourceForeground","description":"Color for renamed or copied resources.","defaults":{"light":"#007100","dark":"#73C991","highContrast":"#73C991","highContrastLight":"#007100"}},{"id":"gitDecoration.untrackedResourceForeground","description":"Color for untracked resources.","defaults":{"light":"#007100","dark":"#73C991","highContrast":"#73C991","highContrastLight":"#007100"}},{"id":"gitDecoration.ignoredResourceForeground","description":"Color for ignored resources.","defaults":{"light":"#8E8E90","dark":"#8C8C8C","highContrast":"#A7A8A9","highContrastLight":"#8e8e90"}},{"id":"gitDecoration.stageModifiedResourceForeground","description":"Color for modified resources which have been staged.","defaults":{"light":"#895503","dark":"#E2C08D","highContrast":"#E2C08D","highContrastLight":"#895503"}},{"id":"gitDecoration.stageDeletedResourceForeground","description":"Color for deleted resources which have been staged.","defaults":{"light":"#ad0707","dark":"#c74e39","highContrast":"#c74e39","highContrastLight":"#ad0707"}},{"id":"gitDecoration.conflictingResourceForeground","description":"Color for resources with conflicts.","defaults":{"light":"#ad0707","dark":"#e4676b","highContrast":"#c74e39","highContrastLight":"#ad0707"}},{"id":"gitDecoration.submoduleResourceForeground","description":"Color for submodule resources.","defaults":{"light":"#1258a7","dark":"#8db9e2","highContrast":"#8db9e2","highContrastLight":"#1258a7"}},{"id":"git.blame.editorDecorationForeground","description":"Color for the blame editor decoration.","defaults":{"dark":"editorInlayHint.foreground","light":"editorInlayHint.foreground","highContrast":"editorInlayHint.foreground","highContrastLight":"editorInlayHint.foreground"}}],"configurationDefaults":{"[git-commit]":{"editor.rulers":[50,72],"editor.wordWrap":"off","workbench.editor.restoreViewState":false},"[git-rebase]":{"workbench.editor.restoreViewState":false}},"viewsWelcome":[{"view":"scm","contents":"If you would like to use Git features, please enable Git in your [settings](command:workbench.action.openSettings?%5B%22git.enabled%22%5D).\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).","when":"!config.git.enabled"},{"view":"scm","contents":"Install Git, a popular source control system, to track code changes and collaborate with others. Learn more in our [Git guides](https://aka.ms/vscode-scm).","when":"config.git.enabled && git.missing && remoteName != ''"},{"view":"scm","contents":"[Download Git for macOS](https://git-scm.com/download/mac)\nAfter installing, please [reload](command:workbench.action.reloadWindow) (or [troubleshoot](command:git.showOutput)). Additional source control providers can be installed [from the Marketplace](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22).","when":"config.git.enabled && git.missing && remoteName == '' && isMac"},{"view":"scm","contents":"[Download Git for Windows](https://git-scm.com/download/win)\nAfter installing, please [reload](command:workbench.action.reloadWindow) (or [troubleshoot](command:git.showOutput)). Additional source control providers can be installed [from the Marketplace](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22).","when":"config.git.enabled && git.missing && remoteName == '' && isWindows"},{"view":"scm","contents":"Source control depends on Git being installed.\n[Download Git for Linux](https://git-scm.com/download/linux)\nAfter installing, please [reload](command:workbench.action.reloadWindow) (or [troubleshoot](command:git.showOutput)). Additional source control providers can be installed [from the Marketplace](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22).","when":"config.git.enabled && git.missing && remoteName == '' && isLinux"},{"view":"scm","contents":"In order to use Git features, you can open a folder containing a Git repository or clone from a URL.\n[Open Folder](command:vscode.openFolder)\n[Clone Repository](command:git.cloneRecursive)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).","when":"config.git.enabled && !git.missing && workbenchState == empty && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0","enablement":"git.state == initialized","group":"2_open@1"},{"view":"scm","contents":"The workspace currently open doesn't have any folders containing Git repositories.\n[Add Folder to Workspace](command:workbench.action.addRootFolder)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).","when":"config.git.enabled && !git.missing && workbenchState == workspace && workspaceFolderCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0","enablement":"git.state == initialized","group":"2_open@1"},{"view":"scm","contents":"Scanning folder for Git repositories...","when":"config.git.enabled && !git.missing && workbenchState == folder && workspaceFolderCount != 0 && git.state != initialized"},{"view":"scm","contents":"Scanning workspace for Git repositories...","when":"config.git.enabled && !git.missing && workbenchState == workspace && workspaceFolderCount != 0 && git.state != initialized"},{"view":"scm","contents":"The folder currently open doesn't have a Git repository. You can initialize a repository which will enable source control features powered by Git.\n[Initialize Repository](command:git.init?%5Btrue%5D)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).","when":"config.git.enabled && !git.missing && git.state == initialized && workbenchState == folder && scm.providerCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0 && remoteName != 'codespaces'","group":"5_scm@1"},{"view":"scm","contents":"The workspace currently open doesn't have any folders containing Git repositories. You can initialize a repository on a folder which will enable source control features powered by Git.\n[Initialize Repository](command:git.init)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).","when":"config.git.enabled && !git.missing && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0 && scm.providerCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0 && remoteName != 'codespaces'","group":"5_scm@1"},{"view":"scm","contents":"A Git repository was found in the parent folders of the workspace or the open file(s).\n[Open Repository](command:git.openRepositoriesInParentFolders)\nUse the [git.openRepositoryInParentFolders](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D) setting to control whether Git repositories in parent folders of workspaces or open files are opened. To learn more [read our docs](https://aka.ms/vscode-git-repository-in-parent-folders).","when":"config.git.enabled && !git.missing && git.state == initialized && git.parentRepositoryCount == 1"},{"view":"scm","contents":"Git repositories were found in the parent folders of the workspace or the open file(s).\n[Open Repository](command:git.openRepositoriesInParentFolders)\nUse the [git.openRepositoryInParentFolders](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D) setting to control whether Git repositories in parent folders of workspace or open files are opened. To learn more [read our docs](https://aka.ms/vscode-git-repository-in-parent-folders).","when":"config.git.enabled && !git.missing && git.state == initialized && git.parentRepositoryCount > 1"},{"view":"scm","contents":"The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user.\n[Manage Unsafe Repositories](command:git.manageUnsafeRepositories)\nTo learn more about unsafe repositories [read our docs](https://aka.ms/vscode-git-unsafe-repository).","when":"config.git.enabled && !git.missing && git.state == initialized && git.unsafeRepositoryCount == 1"},{"view":"scm","contents":"The detected Git repositories are potentially unsafe as the folders are owned by someone other than the current user.\n[Manage Unsafe Repositories](command:git.manageUnsafeRepositories)\nTo learn more about unsafe repositories [read our docs](https://aka.ms/vscode-git-unsafe-repository).","when":"config.git.enabled && !git.missing && git.state == initialized && git.unsafeRepositoryCount > 1"},{"view":"scm","contents":"A Git repository was found that was previously closed.\n[Reopen Closed Repository](command:git.reopenClosedRepositories)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).","when":"config.git.enabled && !git.missing && git.state == initialized && git.closedRepositoryCount == 1"},{"view":"scm","contents":"Git repositories were found that were previously closed.\n[Reopen Closed Repositories](command:git.reopenClosedRepositories)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).","when":"config.git.enabled && !git.missing && git.state == initialized && git.closedRepositoryCount > 1"},{"view":"explorer","contents":"You can clone a repository locally.\n[Clone Repository](command:git.clone 'Clone a repository once the Git extension has activated')","when":"config.git.enabled && git.state == initialized && scm.providerCount == 0","group":"5_scm@1"},{"view":"explorer","contents":"To learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).","when":"config.git.enabled && git.state == initialized && scm.providerCount == 0","group":"5_scm@10"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/git","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.git-base"},"manifest":{"name":"git-base","displayName":"Git Base","description":"Git static contributions and pickers.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"categories":["Other"],"activationEvents":["*"],"main":"./dist/extension.js","browser":"./dist/browser/extension.js","icon":"resources/icons/git.png","capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"contributes":{"commands":[{"command":"git-base.api.getRemoteSources","title":"Get Remote Sources","category":"Git Base API"}],"menus":{"commandPalette":[{"command":"git-base.api.getRemoteSources","when":"false"}]},"languages":[{"id":"git-commit","aliases":["Git Commit Message","git-commit"],"filenames":["COMMIT_EDITMSG","MERGE_MSG"],"configuration":"./languages/git-commit.language-configuration.json"},{"id":"git-rebase","aliases":["Git Rebase Message","git-rebase"],"filenames":["git-rebase-todo"],"filenamePatterns":["**/rebase-merge/done"],"configuration":"./languages/git-rebase.language-configuration.json"},{"id":"ignore","aliases":["Ignore","ignore"],"extensions":[".gitignore_global",".gitignore",".git-blame-ignore-revs"],"configuration":"./languages/ignore.language-configuration.json"}],"grammars":[{"language":"git-commit","scopeName":"text.git-commit","path":"./syntaxes/git-commit.tmLanguage.json"},{"language":"git-rebase","scopeName":"text.git-rebase","path":"./syntaxes/git-rebase.tmLanguage.json"},{"language":"ignore","scopeName":"source.ignore","path":"./syntaxes/ignore.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/git-base","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.github"},"manifest":{"name":"github","displayName":"GitHub","description":"GitHub features for VS Code","publisher":"vscode","license":"MIT","version":"0.0.1","engines":{"vscode":"^1.41.0"},"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","icon":"images/icon.png","categories":["Other"],"activationEvents":["*"],"extensionDependencies":["vscode.git-base"],"main":"./dist/extension.js","type":"module","capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":true}},"enabledApiProposals":["canonicalUriProvider","contribEditSessions","contribShareMenu","contribSourceControlHistoryItemMenu","scmHistoryProvider","shareProvider","timeline"],"contributes":{"commands":[{"command":"github.publish","title":"Publish to GitHub"},{"command":"github.copyVscodeDevLink","title":"Copy vscode.dev Link"},{"command":"github.copyVscodeDevLinkFile","title":"Copy vscode.dev Link"},{"command":"github.copyVscodeDevLinkWithoutRange","title":"Copy vscode.dev Link"},{"command":"github.openOnVscodeDev","title":"Open in vscode.dev","icon":"$(globe)"},{"command":"github.graph.openOnGitHub","title":"Open on GitHub","icon":"$(github)"},{"command":"github.timeline.openOnGitHub","title":"Open on GitHub","icon":"$(github)"}],"continueEditSession":[{"command":"github.openOnVscodeDev","when":"github.hasGitHubRepo","qualifiedName":"Continue Working in vscode.dev","category":"Remote Repositories","remoteGroup":"virtualfs_44_vscode-vfs_2_web@2"}],"menus":{"commandPalette":[{"command":"github.publish","when":"git-base.gitEnabled && workspaceFolderCount != 0 && remoteName != 'codespaces'"},{"command":"github.graph.openOnGitHub","when":"false"},{"command":"github.copyVscodeDevLink","when":"false"},{"command":"github.copyVscodeDevLinkFile","when":"false"},{"command":"github.copyVscodeDevLinkWithoutRange","when":"false"},{"command":"github.openOnVscodeDev","when":"false"},{"command":"github.timeline.openOnGitHub","when":"false"}],"file/share":[{"command":"github.copyVscodeDevLinkFile","when":"github.hasGitHubRepo && remoteName != 'codespaces'","group":"0_vscode@0"}],"editor/context/share":[{"command":"github.copyVscodeDevLink","when":"github.hasGitHubRepo && resourceScheme != untitled && !isInEmbeddedEditor && remoteName != 'codespaces'","group":"0_vscode@0"}],"explorer/context/share":[{"command":"github.copyVscodeDevLinkWithoutRange","when":"github.hasGitHubRepo && resourceScheme != untitled && !isInEmbeddedEditor && remoteName != 'codespaces'","group":"0_vscode@0"}],"editor/lineNumber/context":[{"command":"github.copyVscodeDevLink","when":"github.hasGitHubRepo && resourceScheme != untitled && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on && remoteName != 'codespaces'","group":"1_cutcopypaste@2"},{"command":"github.copyVscodeDevLink","when":"github.hasGitHubRepo && resourceScheme != untitled && activeEditor == workbench.editor.notebook && remoteName != 'codespaces'","group":"1_cutcopypaste@2"}],"editor/title/context/share":[{"command":"github.copyVscodeDevLinkWithoutRange","when":"github.hasGitHubRepo && resourceScheme != untitled && remoteName != 'codespaces'","group":"0_vscode@0"}],"scm/historyItem/context":[{"command":"github.graph.openOnGitHub","when":"github.hasGitHubRepo","group":"0_view@2"}],"timeline/item/context":[{"command":"github.timeline.openOnGitHub","group":"1_actions@3","when":"github.hasGitHubRepo && timelineItem =~ /git:file:commit\\b/"}]},"configuration":[{"title":"GitHub","properties":{"github.branchProtection":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether to query repository rules for GitHub repositories"},"github.gitAuthentication":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether to enable automatic GitHub authentication for git commands within VS Code."},"github.gitProtocol":{"type":"string","enum":["https","ssh"],"default":"https","description":"Controls which protocol is used to clone a GitHub repository"},"github.showAvatar":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether to show the GitHub avatar of the commit author in various hovers (ex: Git blame, Timeline, Source Control Graph, etc.)"}}}],"viewsWelcome":[{"view":"scm","contents":"You can directly publish this folder to a GitHub repository. Once published, you'll have access to source control features powered by Git and GitHub.\n[$(github) Publish to GitHub](command:github.publish)","when":"config.git.enabled && git.state == initialized && workbenchState == folder && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0"},{"view":"scm","contents":"You can directly publish a workspace folder to a GitHub repository. Once published, you'll have access to source control features powered by Git and GitHub.\n[$(github) Publish to GitHub](command:github.publish)","when":"config.git.enabled && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0"}],"markdown.previewStyles":["./markdown.css"]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/github","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.github-authentication"},"manifest":{"name":"github-authentication","displayName":"GitHub Authentication","description":"GitHub Authentication Provider","publisher":"vscode","license":"MIT","version":"0.0.2","engines":{"vscode":"^1.41.0"},"icon":"images/icon.png","categories":["Other"],"api":"none","extensionKind":["ui","workspace"],"enabledApiProposals":["authIssuers","authProviderSpecific","envIsAppPortable"],"activationEvents":[],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":"limited","restrictedConfigurations":["github-enterprise.uri"]}},"contributes":{"authentication":[{"label":"GitHub","id":"github","authorizationServerGlobs":["https://github.com/login/oauth"]},{"label":"GitHub Enterprise Server","id":"github-enterprise","authorizationServerGlobs":["*"]}],"configuration":[{"title":"GHE.com & GitHub Enterprise Server Authentication","properties":{"github-enterprise.uri":{"type":"string","markdownDescription":"The URI for your GHE.com or GitHub Enterprise Server instance.\n\nExamples:\n* GHE.com: `https://octocat.ghe.com`\n* GitHub Enterprise Server: `https://github.octocat.com`\n\n> **Note:** This should _not_ be set to a GitHub.com URI. If your account exists on GitHub.com or is a GitHub Enterprise Managed User, you do not need any additional configuration and can simply log in to GitHub.","pattern":"^(?:$|(https?)://(?!github\\.com).*)"},"github-authentication.useElectronFetch":{"type":"boolean","default":true,"scope":"application","markdownDescription":"When true, uses Electron's built-in fetch function for HTTP requests. When false, uses the Node.js global fetch function. This setting only applies when running in the Electron environment. **Note:** A restart is required for this setting to take effect."},"github-authentication.preferDeviceCodeFlow":{"type":"boolean","default":false,"scope":"application","markdownDescription":"When true, prioritize the device code flow for authentication instead of other available flows. This is useful for environments like WSL where the local server or URL handler flows may not work as expected."}}}]},"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","main":"./dist/extension.js","browser":"./dist/browser/extension.js","repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/github-authentication","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.go"},"manifest":{"name":"go","displayName":"Go Language Basics","description":"Provides syntax highlighting and bracket matching in Go files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin worlpaker/go-syntax syntaxes/go.tmLanguage.json ./syntaxes/go.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"go","extensions":[".go"],"aliases":["Go"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"go","scopeName":"source.go","path":"./syntaxes/go.tmLanguage.json"}],"configurationDefaults":{"[go]":{"editor.insertSpaces":false}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/go","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.groovy"},"manifest":{"name":"groovy","displayName":"Groovy Language Basics","description":"Provides snippets, syntax highlighting and bracket matching in Groovy files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin textmate/groovy.tmbundle Syntaxes/Groovy.tmLanguage ./syntaxes/groovy.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"groovy","aliases":["Groovy","groovy"],"extensions":[".groovy",".gvy",".gradle",".jenkinsfile",".nf"],"filenames":["Jenkinsfile"],"filenamePatterns":["Jenkinsfile*"],"firstLine":"^#!.*\\bgroovy\\b","configuration":"./language-configuration.json"}],"grammars":[{"language":"groovy","scopeName":"source.groovy","path":"./syntaxes/groovy.tmLanguage.json"}],"snippets":[{"language":"groovy","path":"./snippets/groovy.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/groovy","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.grunt"},"manifest":{"name":"grunt","publisher":"vscode","description":"Extension to add Grunt capabilities to VS Code.","displayName":"Grunt support for VS Code","version":"1.0.0","private":true,"icon":"images/grunt.png","license":"MIT","engines":{"vscode":"*"},"categories":["Other"],"main":"./dist/main","activationEvents":["onTaskType:grunt"],"capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":true}},"contributes":{"configuration":{"id":"grunt","type":"object","title":"Grunt","properties":{"grunt.autoDetect":{"scope":"application","type":"string","enum":["off","on"],"default":"off","description":"Controls enablement of Grunt task detection. Grunt task detection can cause files in any open workspace to be executed."}}},"taskDefinitions":[{"type":"grunt","required":["task"],"properties":{"task":{"type":"string","description":"The Grunt task to customize."},"args":{"type":"array","description":"Command line arguments to pass to the grunt task"},"file":{"type":"string","description":"The Grunt file that provides the task. Can be omitted."}},"when":"shellExecutionSupported"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/grunt","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.gulp"},"manifest":{"name":"gulp","publisher":"vscode","description":"Extension to add Gulp capabilities to VSCode.","displayName":"Gulp support for VSCode","version":"1.0.0","icon":"images/gulp.png","license":"MIT","engines":{"vscode":"*"},"categories":["Other"],"main":"./dist/main","activationEvents":["onTaskType:gulp"],"capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":true}},"contributes":{"configuration":{"id":"gulp","type":"object","title":"Gulp","properties":{"gulp.autoDetect":{"scope":"application","type":"string","enum":["off","on"],"default":"off","description":"Controls enablement of Gulp task detection. Gulp task detection can cause files in any open workspace to be executed."}}},"taskDefinitions":[{"type":"gulp","required":["task"],"properties":{"task":{"type":"string","description":"The Gulp task to customize."},"file":{"type":"string","description":"The Gulp file that provides the task. Can be omitted."}},"when":"shellExecutionSupported"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/gulp","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.handlebars"},"manifest":{"name":"handlebars","displayName":"Handlebars Language Basics","description":"Provides syntax highlighting and bracket matching in Handlebars files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin daaain/Handlebars grammars/Handlebars.json ./syntaxes/Handlebars.tmLanguage.json"},"categories":["Programming Languages"],"extensionKind":["ui","workspace"],"contributes":{"languages":[{"id":"handlebars","extensions":[".handlebars",".hbs",".hjs"],"aliases":["Handlebars","handlebars"],"mimetypes":["text/x-handlebars-template"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"handlebars","scopeName":"text.html.handlebars","path":"./syntaxes/Handlebars.tmLanguage.json"}],"htmlLanguageParticipants":[{"languageId":"handlebars","autoInsert":true}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/handlebars","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[[2,"property `extensionKind` can be defined only if property `main` is also defined."]],"preRelease":false},{"type":0,"identifier":{"id":"vscode.hlsl"},"manifest":{"name":"hlsl","displayName":"HLSL Language Basics","description":"Provides syntax highlighting and bracket matching in HLSL files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin tgjones/shaders-tmLanguage grammars/hlsl.json ./syntaxes/hlsl.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"hlsl","extensions":[".hlsl",".hlsli",".fx",".fxh",".vsh",".psh",".cginc",".compute"],"aliases":["HLSL","hlsl"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"hlsl","path":"./syntaxes/hlsl.tmLanguage.json","scopeName":"source.hlsl"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/hlsl","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.html"},"manifest":{"name":"html","displayName":"HTML Language Basics","description":"Provides syntax highlighting, bracket matching & snippets in HTML files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ./build/update-grammar.mjs"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"html","extensions":[".html",".htm",".shtml",".xhtml",".xht",".mdoc",".jsp",".asp",".aspx",".jshtm",".volt",".ejs",".rhtml"],"aliases":["HTML","htm","html","xhtml"],"mimetypes":["text/html","text/x-jshtm","text/template","text/ng-template","application/xhtml+xml"],"configuration":"./language-configuration.json"}],"grammars":[{"scopeName":"text.html.basic","path":"./syntaxes/html.tmLanguage.json","embeddedLanguages":{"text.html":"html","source.css":"css","source.js":"javascript","source.python":"python","source.smarty":"smarty"},"tokenTypes":{"meta.tag string.quoted":"other"}},{"language":"html","scopeName":"text.html.derivative","path":"./syntaxes/html-derivative.tmLanguage.json","embeddedLanguages":{"text.html":"html","source.css":"css","source.js":"javascript","source.python":"python","source.smarty":"smarty"},"tokenTypes":{"meta.tag string.quoted":"other"}}],"snippets":[{"language":"html","path":"./snippets/html.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/html","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.html-language-features"},"manifest":{"name":"html-language-features","displayName":"HTML Language Features","description":"Provides rich language support for HTML and Handlebar files","version":"1.0.0","publisher":"vscode","license":"MIT","aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","engines":{"vscode":"^1.77.0"},"icon":"icons/html.png","activationEvents":["onLanguage:html","onLanguage:handlebars"],"enabledApiProposals":["extensionsAny"],"main":"./client/dist/node/htmlClientMain","browser":"./client/dist/browser/htmlClientMain","capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"categories":["Programming Languages"],"contributes":{"configuration":{"id":"html","order":20,"type":"object","title":"HTML","properties":{"html.completion.attributeDefaultValue":{"type":"string","scope":"resource","enum":["doublequotes","singlequotes","empty"],"enumDescriptions":["Attribute value is set to \"\".","Attribute value is set to ''.","Attribute value is not set."],"default":"doublequotes","markdownDescription":"Controls the default value for attributes when completion is accepted."},"html.customData":{"type":"array","markdownDescription":"A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-html-languageservice/blob/master/docs/customData.md).\n\nVS Code loads custom data on startup to enhance its HTML support for the custom HTML tags, attributes and attribute values you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.","default":[],"items":{"type":"string"},"scope":"resource"},"html.format.enable":{"type":"boolean","scope":"window","default":true,"description":"Enable/disable default HTML formatter."},"html.format.wrapLineLength":{"type":"integer","scope":"resource","default":120,"description":"Maximum amount of characters per line (0 = disable)."},"html.format.unformatted":{"type":["string","null"],"scope":"resource","default":"wbr","markdownDescription":"List of tags, comma separated, that shouldn't be reformatted. `null` defaults to all tags listed at https://www.w3.org/TR/html5/dom.html#phrasing-content."},"html.format.contentUnformatted":{"type":["string","null"],"scope":"resource","default":"pre,code,textarea","markdownDescription":"List of tags, comma separated, where the content shouldn't be reformatted. `null` defaults to the `pre` tag."},"html.format.indentInnerHtml":{"type":"boolean","scope":"resource","default":false,"markdownDescription":"Indent `` and `` sections."},"html.format.preserveNewLines":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether existing line breaks before elements should be preserved. Only works before elements, not inside tags or for text."},"html.format.maxPreserveNewLines":{"type":["number","null"],"scope":"resource","default":null,"markdownDescription":"Maximum number of line breaks to be preserved in one chunk. Use `null` for unlimited."},"html.format.indentHandlebars":{"type":"boolean","scope":"resource","default":false,"markdownDescription":"Format and indent `{{#foo}}` and `{{/foo}}`."},"html.format.extraLiners":{"type":["string","null"],"scope":"resource","default":"head, body, /html","markdownDescription":"List of tags, comma separated, that should have an extra newline before them. `null` defaults to `\"head, body, /html\"`."},"html.format.wrapAttributes":{"type":"string","scope":"resource","default":"auto","enum":["auto","force","force-aligned","force-expand-multiline","aligned-multiple","preserve","preserve-aligned"],"enumDescriptions":["Wrap attributes only when line length is exceeded.","Wrap each attribute except first.","Wrap each attribute except first and keep aligned.","Wrap each attribute.","Wrap when line length is exceeded, align attributes vertically.","Preserve wrapping of attributes.","Preserve wrapping of attributes but align."],"description":"Wrap attributes."},"html.format.wrapAttributesIndentSize":{"type":["number","null"],"scope":"resource","default":null,"markdownDescription":"Indent wrapped attributes to after N characters. Use `null` to use the default indent size. Ignored if `#html.format.wrapAttributes#` is set to `aligned`."},"html.format.templating":{"type":"boolean","scope":"resource","default":false,"description":"Honor django, erb, handlebars and php templating language tags."},"html.format.unformattedContentDelimiter":{"type":"string","scope":"resource","default":"","markdownDescription":"Keep text content together between this string."},"html.suggest.html5":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether the built-in HTML language support suggests HTML5 tags, properties and values."},"html.suggest.hideEndTagSuggestions":{"type":"boolean","scope":"resource","default":false,"description":"Controls whether the built-in HTML language support suggests closing tags. When disabled, end tag completions like `` will not be shown."},"html.validate.scripts":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether the built-in HTML language support validates embedded scripts."},"html.validate.styles":{"type":"boolean","scope":"resource","default":true,"description":"Controls whether the built-in HTML language support validates embedded styles."},"html.autoCreateQuotes":{"type":"boolean","scope":"resource","default":true,"markdownDescription":"Enable/disable auto creation of quotes for HTML attribute assignment. The type of quotes can be configured by `#html.completion.attributeDefaultValue#`."},"html.autoClosingTags":{"type":"boolean","scope":"resource","default":true,"description":"Enable/disable autoclosing of HTML tags."},"html.hover.documentation":{"type":"boolean","scope":"resource","default":true,"description":"Show tag and attribute documentation in hover."},"html.hover.references":{"type":"boolean","scope":"resource","default":true,"description":"Show references to MDN in hover."},"html.mirrorCursorOnMatchingTag":{"type":"boolean","scope":"resource","default":false,"description":"Enable/disable mirroring cursor on matching HTML tag.","deprecationMessage":"Deprecated in favor of `editor.linkedEditing`"},"html.trace.server":{"type":"string","scope":"window","enum":["off","messages","verbose"],"default":"off","description":"Traces the communication between VS Code and the HTML language server."}}},"configurationDefaults":{"[html]":{"editor.suggest.insertMode":"replace"},"[handlebars]":{"editor.suggest.insertMode":"replace"}},"jsonValidation":[{"fileMatch":"*.html-data.json","url":"https://raw.githubusercontent.com/microsoft/vscode-html-languageservice/master/docs/customData.schema.json"},{"fileMatch":"package.json","url":"./schemas/package.schema.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/html-language-features","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.ini"},"manifest":{"name":"ini","displayName":"Ini Language Basics","description":"Provides syntax highlighting and bracket matching in Ini files.","version":"1.0.0","private":true,"publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin textmate/ini.tmbundle Syntaxes/Ini.plist ./syntaxes/ini.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"ini","extensions":[".ini"],"aliases":["Ini","ini"],"configuration":"./ini.language-configuration.json"},{"id":"properties","extensions":[".conf",".properties",".cfg",".directory",".gitattributes",".gitconfig",".gitmodules",".editorconfig",".repo"],"filenames":["gitconfig"],"filenamePatterns":["**/.config/git/config","**/.git/config"],"aliases":["Properties","properties"],"configuration":"./properties.language-configuration.json"}],"grammars":[{"language":"ini","scopeName":"source.ini","path":"./syntaxes/ini.tmLanguage.json"},{"language":"properties","scopeName":"source.ini","path":"./syntaxes/ini.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/ini","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.ipynb"},"manifest":{"name":"ipynb","displayName":".ipynb Support","description":"Provides basic support for opening and reading Jupyter's .ipynb notebook files","publisher":"vscode","version":"1.0.0","license":"MIT","icon":"media/icon.png","engines":{"vscode":"^1.57.0"},"enabledApiProposals":["diffContentOptions"],"activationEvents":["onNotebook:jupyter-notebook","onNotebookSerializer:interactive","onNotebookSerializer:repl"],"extensionKind":["workspace","ui"],"main":"./dist/ipynbMain.node.js","browser":"./dist/browser/ipynbMain.browser.js","capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"contributes":{"configuration":[{"properties":{"ipynb.pasteImagesAsAttachments.enabled":{"type":"boolean","scope":"resource","markdownDescription":"Enable/disable pasting of images into Markdown cells in ipynb notebook files. Pasted images are inserted as attachments to the cell.","default":true},"ipynb.experimental.serialization":{"type":"boolean","scope":"resource","markdownDescription":"Experimental feature to serialize the Jupyter notebook in a worker thread.","default":true,"tags":["experimental"]}}}],"commands":[{"command":"ipynb.newUntitledIpynb","title":"New Jupyter Notebook","shortTitle":"Jupyter Notebook","category":"Create"},{"command":"ipynb.openIpynbInNotebookEditor","title":"Open IPYNB File In Notebook Editor"},{"command":"ipynb.cleanInvalidImageAttachment","title":"Clean Invalid Image Attachment Reference"},{"command":"notebook.cellOutput.copy","title":"Copy Cell Output","category":"Notebook"},{"command":"notebook.cellOutput.addToChat","title":"Add Cell Output to Chat","category":"Notebook","enablement":"chatIsEnabled"},{"command":"notebook.cellOutput.openInTextEditor","title":"Open Cell Output in Text Editor","category":"Notebook"}],"notebooks":[{"type":"jupyter-notebook","displayName":"Jupyter Notebook","selector":[{"filenamePattern":"*.ipynb"}],"priority":"default"}],"notebookRenderer":[{"id":"vscode.markdown-it-cell-attachment-renderer","displayName":"Markdown-It ipynb Cell Attachment renderer","entrypoint":{"extends":"vscode.markdown-it-renderer","path":"./notebook-out/cellAttachmentRenderer.js"}}],"menus":{"file/newFile":[{"command":"ipynb.newUntitledIpynb","group":"notebook"}],"commandPalette":[{"command":"ipynb.newUntitledIpynb"},{"command":"ipynb.openIpynbInNotebookEditor","when":"false"},{"command":"ipynb.cleanInvalidImageAttachment","when":"false"},{"command":"notebook.cellOutput.copy","when":"notebookCellHasOutputs"},{"command":"notebook.cellOutput.openInTextEditor","when":"false"}],"webview/context":[{"command":"notebook.cellOutput.copy","when":"webviewId == 'notebook.output' && webviewSection == 'image'","group":"context@1"},{"command":"notebook.cellOutput.copy","when":"webviewId == 'notebook.output' && webviewSection == 'text'"},{"command":"notebook.cellOutput.addToChat","when":"webviewId == 'notebook.output' && (webviewSection == 'text' || webviewSection == 'image')","group":"context@2"},{"command":"notebook.cellOutput.openInTextEditor","when":"webviewId == 'notebook.output' && webviewSection == 'text'"}]}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/ipynb","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.jake"},"manifest":{"name":"jake","publisher":"vscode","description":"Extension to add Jake capabilities to VS Code.","displayName":"Jake support for VS Code","icon":"images/cowboy_hat.png","version":"1.0.0","license":"MIT","engines":{"vscode":"*"},"categories":["Other"],"main":"./dist/main","activationEvents":["onTaskType:jake"],"capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":true}},"contributes":{"configuration":{"id":"jake","type":"object","title":"Jake","properties":{"jake.autoDetect":{"scope":"application","type":"string","enum":["off","on"],"default":"off","description":"Controls enablement of Jake task detection. Jake task detection can cause files in any open workspace to be executed."}}},"taskDefinitions":[{"type":"jake","required":["task"],"properties":{"task":{"type":"string","description":"The Jake task to customize."},"file":{"type":"string","description":"The Jake file that provides the task. Can be omitted."}},"when":"shellExecutionSupported"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/jake","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.java"},"manifest":{"name":"java","displayName":"Java Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in Java files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin redhat-developer/vscode-java language-support/java/java.tmLanguage.json ./syntaxes/java.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"java","extensions":[".java",".jav"],"aliases":["Java","java"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"java","scopeName":"source.java","path":"./syntaxes/java.tmLanguage.json"}],"snippets":[{"language":"java","path":"./snippets/java.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/java","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.javascript"},"manifest":{"name":"javascript","displayName":"JavaScript Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in JavaScript files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"categories":["Programming Languages"],"contributes":{"configurationDefaults":{"[javascript]":{"editor.maxTokenizationLineLength":2500}},"languages":[{"id":"javascriptreact","aliases":["JavaScript JSX","JavaScript React","jsx"],"extensions":[".jsx"],"configuration":"./javascript-language-configuration.json"},{"id":"javascript","aliases":["JavaScript","javascript","js"],"extensions":[".js",".es6",".mjs",".cjs",".pac"],"filenames":["jakefile"],"firstLine":"^#!.*\\bnode","mimetypes":["text/javascript"],"configuration":"./javascript-language-configuration.json"},{"id":"jsx-tags","aliases":[],"configuration":"./tags-language-configuration.json"}],"grammars":[{"language":"javascriptreact","scopeName":"source.js.jsx","path":"./syntaxes/JavaScriptReact.tmLanguage.json","embeddedLanguages":{"meta.tag.js":"jsx-tags","meta.tag.without-attributes.js":"jsx-tags","meta.tag.attributes.js.jsx":"javascriptreact","meta.embedded.expression.js":"javascriptreact"},"tokenTypes":{"punctuation.definition.template-expression":"other","entity.name.type.instance.jsdoc":"other","entity.name.function.tagged-template":"other","meta.import string.quoted":"other","variable.other.jsdoc":"other"}},{"language":"javascript","scopeName":"source.js","path":"./syntaxes/JavaScript.tmLanguage.json","embeddedLanguages":{"meta.tag.js":"jsx-tags","meta.tag.without-attributes.js":"jsx-tags","meta.tag.attributes.js":"javascript","meta.embedded.expression.js":"javascript"},"tokenTypes":{"punctuation.definition.template-expression":"other","entity.name.type.instance.jsdoc":"other","entity.name.function.tagged-template":"other","meta.import string.quoted":"other","variable.other.jsdoc":"other"}},{"scopeName":"source.js.regexp","path":"./syntaxes/Regular Expressions (JavaScript).tmLanguage"}],"semanticTokenScopes":[{"language":"javascript","scopes":{"property":["variable.other.property.js"],"property.readonly":["variable.other.constant.property.js"],"variable":["variable.other.readwrite.js"],"variable.readonly":["variable.other.constant.object.js"],"function":["entity.name.function.js"],"namespace":["entity.name.type.module.js"],"variable.defaultLibrary":["support.variable.js"],"function.defaultLibrary":["support.function.js"]}},{"language":"javascriptreact","scopes":{"property":["variable.other.property.jsx"],"property.readonly":["variable.other.constant.property.jsx"],"variable":["variable.other.readwrite.jsx"],"variable.readonly":["variable.other.constant.object.jsx"],"function":["entity.name.function.jsx"],"namespace":["entity.name.type.module.jsx"],"variable.defaultLibrary":["support.variable.js"],"function.defaultLibrary":["support.function.js"]}}],"snippets":[{"language":"javascript","path":"./snippets/javascript.code-snippets"},{"language":"javascriptreact","path":"./snippets/javascript.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/javascript","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.json"},"manifest":{"name":"json","displayName":"JSON Language Basics","description":"Provides syntax highlighting & bracket matching in JSON files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ./build/update-grammars.js"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"json","aliases":["JSON","json"],"extensions":[".json",".bowerrc",".jscsrc",".webmanifest",".js.map",".css.map",".ts.map",".har",".jslintrc",".jsonld",".geojson",".ipynb",".vuerc"],"filenames":["composer.lock",".watchmanconfig"],"mimetypes":["application/json","application/manifest+json"],"configuration":"./language-configuration.json"},{"id":"jsonc","aliases":["JSON with Comments"],"extensions":[".jsonc",".eslintrc",".eslintrc.json",".jsfmtrc",".jshintrc",".swcrc",".hintrc",".babelrc",".toolset.jsonc"],"filenames":["babel.config.json","bun.lock",".babelrc.json",".ember-cli","typedoc.json"],"configuration":"./language-configuration.json"},{"id":"jsonl","aliases":["JSON Lines"],"extensions":[".jsonl",".ndjson"],"filenames":[],"configuration":"./language-configuration.json"},{"id":"snippets","aliases":["Code Snippets"],"extensions":[".code-snippets"],"filenamePatterns":["**/User/snippets/*.json","**/User/profiles/*/snippets/*.json","**/snippets*.json"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"json","scopeName":"source.json","path":"./syntaxes/JSON.tmLanguage.json"},{"language":"jsonc","scopeName":"source.json.comments","path":"./syntaxes/JSONC.tmLanguage.json"},{"language":"jsonl","scopeName":"source.json.lines","path":"./syntaxes/JSONL.tmLanguage.json"},{"language":"snippets","scopeName":"source.json.comments.snippets","path":"./syntaxes/snippets.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/json","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.json-language-features"},"manifest":{"name":"json-language-features","displayName":"JSON Language Features","description":"Provides rich language support for JSON files.","version":"1.0.0","publisher":"vscode","license":"MIT","aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","engines":{"vscode":"^1.77.0"},"enabledApiProposals":["extensionsAny"],"icon":"icons/json.png","activationEvents":["onLanguage:json","onLanguage:jsonc","onLanguage:snippets","onCommand:json.validate"],"main":"./client/dist/node/jsonClientMain","browser":"./client/dist/browser/jsonClientMain","capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":"limited","description":"The extension requires workspace trust to load schemas from http and https."}},"categories":["Programming Languages"],"contributes":{"configuration":{"id":"json","order":20,"type":"object","title":"JSON","properties":{"json.schemas":{"type":"array","scope":"resource","description":"Associate schemas to JSON files in the current project.","items":{"type":"object","default":{"fileMatch":["/myfile"],"url":"schemaURL"},"properties":{"url":{"type":"string","default":"/user.schema.json","description":"A URL or absolute file path to a schema. Can be a relative path (starting with './') in workspace and workspace folder settings."},"fileMatch":{"type":"array","items":{"type":"string","default":"MyFile.json","description":"A file pattern that can contain '*' and '**' to match against when resolving JSON files to schemas. When beginning with '!', it defines an exclusion pattern."},"minItems":1,"description":"An array of file patterns to match against when resolving JSON files to schemas. `*` and '**' can be used as a wildcard. Exclusion patterns can also be defined and start with '!'. A file matches when there is at least one matching pattern and the last matching pattern is not an exclusion pattern."},"schema":{"$ref":"http://json-schema.org/draft-07/schema#","description":"The schema definition for the given URL. The schema only needs to be provided to avoid accesses to the schema URL."}}}},"json.validate.enable":{"type":"boolean","scope":"window","default":true,"description":"Enable/disable JSON validation."},"json.format.enable":{"type":"boolean","scope":"window","default":true,"description":"Enable/disable default JSON formatter"},"json.format.keepLines":{"type":"boolean","scope":"window","default":false,"description":"Keep all existing new lines when formatting."},"json.trace.server":{"type":"string","scope":"window","enum":["off","messages","verbose"],"default":"off","description":"Traces the communication between VS Code and the JSON language server."},"json.colorDecorators.enable":{"type":"boolean","scope":"window","default":true,"description":"Enables or disables color decorators","deprecationMessage":"The setting `json.colorDecorators.enable` has been deprecated in favor of `editor.colorDecorators`."},"json.maxItemsComputed":{"type":"number","default":5000,"description":"The maximum number of outline symbols and folding regions computed (limited for performance reasons)."},"json.schemaDownload.enable":{"type":"boolean","default":true,"description":"When enabled, JSON schemas can be fetched from http and https locations.","tags":["usesOnlineServices"]},"json.schemaDownload.trustedDomains":{"type":"object","default":{"https://schemastore.azurewebsites.net/":true,"https://raw.githubusercontent.com/":true,"https://www.schemastore.org/":true,"https://json.schemastore.org/":true,"https://json-schema.org/":true},"additionalProperties":{"type":"boolean"},"description":"List of trusted domains for downloading JSON schemas over http(s). Use '*' to trust all domains. '*' can also be used as a wildcard in domain names.","tags":["usesOnlineServices"]}}},"configurationDefaults":{"[json]":{"editor.quickSuggestions":{"strings":true},"editor.suggest.insertMode":"replace"},"[jsonc]":{"editor.quickSuggestions":{"strings":true},"editor.suggest.insertMode":"replace"},"[snippets]":{"editor.quickSuggestions":{"strings":true},"editor.suggest.insertMode":"replace"}},"jsonValidation":[{"fileMatch":"*.schema.json","url":"http://json-schema.org/draft-07/schema#"}],"commands":[{"command":"json.clearCache","title":"Clear Schema Cache","category":"JSON"},{"command":"json.sort","title":"Sort Document","category":"JSON"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/json-language-features","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.julia"},"manifest":{"name":"julia","displayName":"Julia Language Basics","description":"Provides syntax highlighting & bracket matching in Julia files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin JuliaEditorSupport/atom-language-julia grammars/julia_vscode.json ./syntaxes/julia.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"julia","aliases":["Julia","julia"],"extensions":[".jl"],"firstLine":"^#!\\s*/.*\\bjulia[0-9.-]*\\b","configuration":"./language-configuration.json"},{"id":"juliamarkdown","aliases":["Julia Markdown","juliamarkdown"],"extensions":[".jmd"]}],"grammars":[{"language":"julia","scopeName":"source.julia","path":"./syntaxes/julia.tmLanguage.json","embeddedLanguages":{"meta.embedded.inline.cpp":"cpp","meta.embedded.inline.javascript":"javascript","meta.embedded.inline.python":"python","meta.embedded.inline.r":"r","meta.embedded.inline.sql":"sql"}}],"configurationDefaults":{"[julia]":{"editor.defaultColorDecorators":"never"}}}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/julia","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.latex"},"manifest":{"name":"latex","displayName":"LaTeX Language Basics","description":"Provides syntax highlighting and bracket matching for TeX, LaTeX and BibTeX.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ./build/update-grammars.js"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"tex","aliases":["TeX","tex"],"extensions":[".sty",".cls",".bbx",".cbx"],"configuration":"latex-language-configuration.json"},{"id":"latex","aliases":["LaTeX","latex"],"extensions":[".tex",".ltx",".ctx"],"configuration":"latex-language-configuration.json"},{"id":"bibtex","aliases":["BibTeX","bibtex"],"extensions":[".bib"]},{"id":"cpp_embedded_latex","configuration":"latex-cpp-embedded-language-configuration.json","aliases":[]},{"id":"markdown_latex_combined","configuration":"markdown-latex-combined-language-configuration.json","aliases":[]}],"grammars":[{"language":"tex","scopeName":"text.tex","path":"./syntaxes/TeX.tmLanguage.json","unbalancedBracketScopes":["keyword.control.ifnextchar.tex","punctuation.math.operator.tex"]},{"language":"latex","scopeName":"text.tex.latex","path":"./syntaxes/LaTeX.tmLanguage.json","unbalancedBracketScopes":["keyword.control.ifnextchar.tex","punctuation.math.operator.tex"],"embeddedLanguages":{"source.cpp":"cpp_embedded_latex","source.css":"css","text.html":"html","source.java":"java","source.js":"javascript","source.julia":"julia","source.lua":"lua","source.python":"python","source.ruby":"ruby","source.ts":"typescript","text.xml":"xml","source.yaml":"yaml","meta.embedded.markdown_latex_combined":"markdown_latex_combined"}},{"language":"bibtex","scopeName":"text.bibtex","path":"./syntaxes/Bibtex.tmLanguage.json"},{"language":"markdown_latex_combined","scopeName":"text.tex.markdown_latex_combined","path":"./syntaxes/markdown-latex-combined.tmLanguage.json"},{"language":"cpp_embedded_latex","scopeName":"source.cpp.embedded.latex","path":"./syntaxes/cpp-grammar-bailout.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/latex","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.less"},"manifest":{"name":"less","displayName":"Less Language Basics","description":"Provides syntax highlighting, bracket matching and folding in Less files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ./build/update-grammar.js"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"less","aliases":["Less","less"],"extensions":[".less"],"mimetypes":["text/x-less","text/less"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"less","scopeName":"source.css.less","path":"./syntaxes/less.tmLanguage.json"}],"problemMatchers":[{"name":"lessc","label":"Lessc compiler","owner":"lessc","source":"less","fileLocation":"absolute","pattern":{"regexp":"(.*)\\sin\\s(.*)\\son line\\s(\\d+),\\scolumn\\s(\\d+)","message":1,"file":2,"line":3,"column":4}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/less","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.log"},"manifest":{"name":"log","displayName":"Log","description":"Provides syntax highlighting for files with .log extension.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin emilast/vscode-logfile-highlighter syntaxes/log.tmLanguage ./syntaxes/log.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"log","extensions":[".log","*.log.?"],"aliases":["Log"]}],"grammars":[{"language":"log","scopeName":"text.log","path":"./syntaxes/log.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/log","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.lua"},"manifest":{"name":"lua","displayName":"Lua Language Basics","description":"Provides syntax highlighting and bracket matching in Lua files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin sumneko/lua.tmbundle Syntaxes/Lua.plist ./syntaxes/lua.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"lua","extensions":[".lua"],"aliases":["Lua","lua"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"lua","scopeName":"source.lua","path":"./syntaxes/lua.tmLanguage.json","tokenTypes":{"comment.line.double-dash.doc.lua":"other"}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/lua","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.make"},"manifest":{"name":"make","displayName":"Make Language Basics","description":"Provides syntax highlighting and bracket matching in Make files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin fadeevab/make.tmbundle Syntaxes/Makefile.plist ./syntaxes/make.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"makefile","aliases":["Makefile","makefile"],"extensions":[".mak",".mk"],"filenames":["Makefile","makefile","GNUmakefile","OCamlMakefile"],"firstLine":"^#!\\s*/usr/bin/make","configuration":"./language-configuration.json"}],"grammars":[{"language":"makefile","scopeName":"source.makefile","path":"./syntaxes/make.tmLanguage.json","tokenTypes":{"string.interpolated":"other"}}],"configurationDefaults":{"[makefile]":{"editor.insertSpaces":false}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/make","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.markdown"},"manifest":{"name":"markdown","displayName":"Markdown Language Basics","description":"Provides snippets and syntax highlighting for Markdown.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.20.0"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"markdown","aliases":["Markdown","markdown"],"extensions":[".md",".mkd",".mdwn",".mdown",".markdown",".markdn",".mdtxt",".mdtext",".workbook"],"filenamePatterns":["**/.cursor/**/*.mdc"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"markdown","scopeName":"text.html.markdown","path":"./syntaxes/markdown.tmLanguage.json","embeddedLanguages":{"meta.embedded.block.html":"html","source.js":"javascript","source.css":"css","meta.embedded.block.frontmatter":"yaml","meta.embedded.block.css":"css","meta.embedded.block.ini":"ini","meta.embedded.block.java":"java","meta.embedded.block.lua":"lua","meta.embedded.block.makefile":"makefile","meta.embedded.block.perl":"perl","meta.embedded.block.r":"r","meta.embedded.block.ruby":"ruby","meta.embedded.block.php":"php","meta.embedded.block.sql":"sql","meta.embedded.block.vs_net":"vs_net","meta.embedded.block.xml":"xml","meta.embedded.block.xsl":"xsl","meta.embedded.block.yaml":"yaml","meta.embedded.block.dosbatch":"dosbatch","meta.embedded.block.clojure":"clojure","meta.embedded.block.coffee":"coffee","meta.embedded.block.c":"c","meta.embedded.block.cpp":"cpp","meta.embedded.block.diff":"diff","meta.embedded.block.dockerfile":"dockerfile","meta.embedded.block.go":"go","meta.embedded.block.groovy":"groovy","meta.embedded.block.pug":"jade","meta.embedded.block.ignore":"ignore","meta.embedded.block.javascript":"javascript","meta.embedded.block.json":"json","meta.embedded.block.jsonc":"jsonc","meta.embedded.block.jsonl":"jsonl","meta.embedded.block.latex":"latex","meta.embedded.block.less":"less","meta.embedded.block.objc":"objc","meta.embedded.block.scss":"scss","meta.embedded.block.perl6":"perl6","meta.embedded.block.powershell":"powershell","meta.embedded.block.python":"python","meta.embedded.block.restructuredtext":"restructuredtext","meta.embedded.block.rust":"rust","meta.embedded.block.scala":"scala","meta.embedded.block.shellscript":"shellscript","meta.embedded.block.typescript":"typescript","meta.embedded.block.typescriptreact":"typescriptreact","meta.embedded.block.csharp":"csharp","meta.embedded.block.fsharp":"fsharp"},"unbalancedBracketScopes":["markup.underline.link.markdown","punctuation.definition.list.begin.markdown"]}],"snippets":[{"language":"markdown","path":"./snippets/markdown.code-snippets"}],"configurationDefaults":{"[markdown]":{"editor.unicodeHighlight.ambiguousCharacters":false,"editor.unicodeHighlight.invisibleCharacters":false,"diffEditor.ignoreTrimWhitespace":false}}},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin microsoft/vscode-markdown-tm-grammar syntaxes/markdown.tmLanguage ./syntaxes/markdown.tmLanguage.json"},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/markdown-basics","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.markdown-language-features"},"manifest":{"name":"markdown-language-features","displayName":"Markdown Language Features","description":"Provides rich language support for Markdown.","version":"1.0.0","icon":"icon.png","publisher":"vscode","license":"MIT","aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","engines":{"vscode":"^1.70.0"},"main":"./dist/extension","browser":"./dist/browser/extension","categories":["Programming Languages"],"activationEvents":["onLanguage:markdown","onLanguage:prompt","onLanguage:instructions","onLanguage:chatagent","onLanguage:skill","onCommand:markdown.api.render","onCommand:markdown.api.reloadPlugins","onWebviewPanel:markdown.preview"],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":"limited","description":"Required for loading styles configured in the workspace.","restrictedConfigurations":["markdown.styles"]}},"contributes":{"notebookRenderer":[{"id":"vscode.markdown-it-renderer","displayName":"Markdown it renderer","entrypoint":"./notebook-out/index.js","mimeTypes":["text/markdown","text/latex","text/x-css","text/x-html","text/x-json","text/x-typescript","text/x-abap","text/x-apex","text/x-azcli","text/x-bat","text/x-cameligo","text/x-clojure","text/x-coffee","text/x-cpp","text/x-csharp","text/x-csp","text/x-css","text/x-dart","text/x-dockerfile","text/x-ecl","text/x-fsharp","text/x-go","text/x-graphql","text/x-handlebars","text/x-hcl","text/x-html","text/x-ini","text/x-java","text/x-javascript","text/x-julia","text/x-kotlin","text/x-less","text/x-lexon","text/x-lua","text/x-m3","text/x-markdown","text/x-mips","text/x-msdax","text/x-mysql","text/x-objective-c/objective","text/x-pascal","text/x-pascaligo","text/x-perl","text/x-pgsql","text/x-php","text/x-postiats","text/x-powerquery","text/x-powershell","text/x-pug","text/x-python","text/x-r","text/x-razor","text/x-redis","text/x-redshift","text/x-restructuredtext","text/x-ruby","text/x-rust","text/x-sb","text/x-scala","text/x-scheme","text/x-scss","text/x-shell","text/x-solidity","text/x-sophia","text/x-sql","text/x-st","text/x-swift","text/x-systemverilog","text/x-tcl","text/x-twig","text/x-typescript","text/x-vb","text/x-xml","text/x-yaml","application/json"]}],"commands":[{"command":"_markdown.copyImage","title":"Copy Image","category":"Markdown"},{"command":"_markdown.openImage","title":"Open Image","category":"Markdown"},{"command":"markdown.showPreview","title":"Open Preview","category":"Markdown","icon":{"light":"./media/preview-light.svg","dark":"./media/preview-dark.svg"}},{"command":"markdown.showPreviewToSide","title":"Open Preview to the Side","category":"Markdown","icon":"$(open-preview)"},{"command":"markdown.showLockedPreviewToSide","title":"Open Locked Preview to the Side","category":"Markdown","icon":"$(open-preview)"},{"command":"markdown.showSource","title":"Show Source","category":"Markdown","icon":"$(go-to-file)"},{"command":"markdown.showPreviewSecuritySelector","title":"Change Preview Security Settings","category":"Markdown"},{"command":"markdown.preview.refresh","title":"Refresh Preview","category":"Markdown"},{"command":"markdown.preview.toggleLock","title":"Toggle Preview Locking","category":"Markdown"},{"command":"markdown.findAllFileReferences","title":"Find File References","category":"Markdown"},{"command":"markdown.editor.insertLinkFromWorkspace","title":"Insert Link to File in Workspace","category":"Markdown","enablement":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !activeEditorIsReadonly"},{"command":"markdown.editor.insertImageFromWorkspace","title":"Insert Image from Workspace","category":"Markdown","enablement":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !activeEditorIsReadonly"}],"menus":{"webview/context":[{"command":"_markdown.copyImage","when":"webviewId == 'markdown.preview' && (webviewSection == 'image' || webviewSection == 'localImage')"},{"command":"_markdown.openImage","when":"webviewId == 'markdown.preview' && webviewSection == 'localImage'"}],"editor/title":[{"command":"markdown.showPreviewToSide","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview","alt":"markdown.showPreview","group":"navigation"},{"command":"markdown.showSource","when":"activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'","group":"navigation"},{"command":"markdown.preview.refresh","when":"activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'","group":"1_markdown"},{"command":"markdown.preview.toggleLock","when":"activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'","group":"1_markdown"},{"command":"markdown.showPreviewSecuritySelector","when":"activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'","group":"1_markdown"}],"explorer/context":[{"command":"markdown.showPreview","when":"resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !hasCustomMarkdownPreview","group":"navigation"},{"command":"markdown.findAllFileReferences","when":"resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/","group":"4_search"}],"editor/title/context":[{"command":"markdown.showPreview","when":"resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !hasCustomMarkdownPreview","group":"1_open"},{"command":"markdown.findAllFileReferences","when":"resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/"}],"commandPalette":[{"command":"_markdown.openImage","when":"false"},{"command":"_markdown.copyImage","when":"false"},{"command":"markdown.showPreview","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused","group":"navigation"},{"command":"markdown.showPreviewToSide","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused","group":"navigation"},{"command":"markdown.showLockedPreviewToSide","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused","group":"navigation"},{"command":"markdown.showSource","when":"activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'","group":"navigation"},{"command":"markdown.showPreviewSecuritySelector","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused"},{"command":"markdown.showPreviewSecuritySelector","when":"activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'"},{"command":"markdown.preview.toggleLock","when":"activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'"},{"command":"markdown.preview.refresh","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused"},{"command":"markdown.preview.refresh","when":"activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor'"},{"command":"markdown.findAllFileReferences","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/"}]},"keybindings":[{"command":"markdown.showPreview","key":"shift+ctrl+v","mac":"shift+cmd+v","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused"},{"command":"markdown.showPreviewToSide","key":"ctrl+k v","mac":"cmd+k v","when":"editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused"}],"configuration":{"type":"object","title":"Markdown","order":20,"properties":{"markdown.styles":{"type":"array","items":{"type":"string"},"default":[],"description":"A list of URLs or local paths to CSS style sheets to use from the Markdown preview. Relative paths are interpreted relative to the folder open in the Explorer. If there is no open folder, they are interpreted relative to the location of the Markdown file. All '\\' need to be written as '\\\\'.","scope":"resource"},"markdown.preview.breaks":{"type":"boolean","default":false,"markdownDescription":"Sets how line-breaks are rendered in the Markdown preview. Setting it to `true` creates a `
` for newlines inside paragraphs.","scope":"resource"},"markdown.preview.linkify":{"type":"boolean","default":true,"description":"Convert URL-like text to links in the Markdown preview.","scope":"resource"},"markdown.preview.typographer":{"type":"boolean","default":false,"description":"Enable some language-neutral replacement and quotes beautification in the Markdown preview.","scope":"resource"},"markdown.preview.fontFamily":{"type":"string","default":"-apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', system-ui, 'Ubuntu', 'Droid Sans', sans-serif","description":"Controls the font family used in the Markdown preview.","scope":"resource"},"markdown.preview.fontSize":{"type":"number","default":14,"description":"Controls the font size in pixels used in the Markdown preview.","scope":"resource"},"markdown.preview.lineHeight":{"type":"number","default":1.6,"description":"Controls the line height used in the Markdown preview. This number is relative to the font size.","scope":"resource"},"markdown.preview.scrollPreviewWithEditor":{"type":"boolean","default":true,"description":"When a Markdown editor is scrolled, update the view of the preview.","scope":"resource"},"markdown.preview.markEditorSelection":{"type":"boolean","default":true,"description":"Mark the current editor selection in the Markdown preview.","scope":"resource"},"markdown.preview.scrollEditorWithPreview":{"type":"boolean","default":true,"description":"When a Markdown preview is scrolled, update the view of the editor.","scope":"resource"},"markdown.preview.doubleClickToSwitchToEditor":{"type":"boolean","default":true,"description":"Double-click in the Markdown preview to switch to the editor.","scope":"resource"},"markdown.preview.openMarkdownLinks":{"type":"string","default":"inPreview","description":"Controls how links to other Markdown files in the Markdown preview should be opened.","scope":"resource","enum":["inPreview","inEditor"],"enumDescriptions":["Try to open links in the Markdown preview.","Try to open links in the editor."]},"markdown.links.openLocation":{"type":"string","default":"currentGroup","description":"Controls where links in Markdown files should be opened.","scope":"resource","enum":["currentGroup","beside"],"enumDescriptions":["Open links in the active editor group.","Open links beside the active editor."]},"markdown.suggest.paths.enabled":{"type":"boolean","default":true,"description":"Enable path suggestions while writing links in Markdown files.","scope":"resource"},"markdown.suggest.paths.includeWorkspaceHeaderCompletions":{"type":"string","default":"onDoubleHash","scope":"resource","markdownDescription":"Enable suggestions for headers in other Markdown files in the current workspace. Accepting one of these suggestions inserts the full path to header in that file, for example: `[link text](/path/to/file.md#header)`.","enum":["never","onDoubleHash","onSingleOrDoubleHash"],"markdownEnumDescriptions":["Disable workspace header suggestions.","Enable workspace header suggestions after typing `##` in a path, for example: `[link text](##`.","Enable workspace header suggestions after typing either `##` or `#` in a path, for example: `[link text](#` or `[link text](##`."]},"markdown.trace.server":{"type":"string","scope":"window","enum":["off","messages","verbose"],"default":"off","description":"Traces the communication between VS Code and the Markdown language server."},"markdown.server.log":{"type":"string","scope":"window","enum":["off","debug","trace"],"default":"off","description":"Controls the logging level of the Markdown language server."},"markdown.editor.drop.enabled":{"type":"string","scope":"resource","markdownDescription":"Enable dropping files into a Markdown editor while holding Shift. Requires enabling `#editor.dropIntoEditor.enabled#`.","default":"smart","enum":["always","smart","never"],"markdownEnumDescriptions":["Always insert Markdown links.","Smartly create Markdown links by default when not dropping into a code block or other special element. Use the drop widget to switch between pasting as plain text or as Markdown links.","Never create Markdown links."]},"markdown.editor.drop.copyIntoWorkspace":{"type":"string","markdownDescription":"Controls if files outside of the workspace that are dropped into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied dropped files should be created","default":"mediaFiles","enum":["mediaFiles","never"],"markdownEnumDescriptions":["Try to copy external image and video files into the workspace.","Do not copy external files into the workspace."]},"markdown.editor.filePaste.enabled":{"type":"string","scope":"resource","markdownDescription":"Enable pasting files into a Markdown editor to create Markdown links. Requires enabling `#editor.pasteAs.enabled#`.","default":"smart","enum":["always","smart","never"],"markdownEnumDescriptions":["Always insert Markdown links.","Smartly create Markdown links by default when not pasting into a code block or other special element. Use the paste widget to switch between pasting as plain text or as Markdown links.","Never create Markdown links."]},"markdown.editor.filePaste.copyIntoWorkspace":{"type":"string","markdownDescription":"Controls if files outside of the workspace that are pasted into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied files should be created.","default":"mediaFiles","enum":["mediaFiles","never"],"markdownEnumDescriptions":["Try to copy external image and video files into the workspace.","Do not copy external files into the workspace."]},"markdown.editor.filePaste.videoSnippet":{"type":"string","markdownDescription":"Snippet used when adding videos to Markdown. This snippet can use the following variables:\n- `${src}` — The resolved path of the video file.\n- `${title}` — The title used for the video. A snippet placeholder will automatically be created for this variable.","default":""},"markdown.editor.filePaste.audioSnippet":{"type":"string","markdownDescription":"Snippet used when adding audio to Markdown. This snippet can use the following variables:\n- `${src}` — The resolved path of the audio file.\n- `${title}` — The title used for the audio. A snippet placeholder will automatically be created for this variable.","default":""},"markdown.editor.pasteUrlAsFormattedLink.enabled":{"type":"string","scope":"resource","markdownDescription":"Controls if Markdown links are created when URLs are pasted into a Markdown editor. Requires enabling `#editor.pasteAs.enabled#`.","default":"smartWithSelection","enum":["always","smart","smartWithSelection","never"],"markdownEnumDescriptions":["Always insert Markdown links.","Smartly create Markdown links by default when not pasting into a code block or other special element. Use the paste widget to switch between pasting as plain text or as Markdown links.","Smartly create Markdown links by default when you have selected text and are not pasting into a code block or other special element. Use the paste widget to switch between pasting as plain text or as Markdown links.","Never create Markdown links."]},"markdown.validate.enabled":{"type":"boolean","scope":"resource","description":"Enable all error reporting in Markdown files.","default":false},"markdown.validate.referenceLinks.enabled":{"type":"string","scope":"resource","markdownDescription":"Validate reference links in Markdown files, for example: `[link][ref]`. Requires enabling `#markdown.validate.enabled#`.","default":"warning","enum":["ignore","warning","error"]},"markdown.validate.fragmentLinks.enabled":{"type":"string","scope":"resource","markdownDescription":"Validate fragment links to headers in the current Markdown file, for example: `[link](#header)`. Requires enabling `#markdown.validate.enabled#`.","default":"warning","enum":["ignore","warning","error"]},"markdown.validate.fileLinks.enabled":{"type":"string","scope":"resource","markdownDescription":"Validate links to other files in Markdown files, for example `[link](/path/to/file.md)`. This checks that the target files exists. Requires enabling `#markdown.validate.enabled#`.","default":"warning","enum":["ignore","warning","error"]},"markdown.validate.fileLinks.markdownFragmentLinks":{"type":"string","scope":"resource","markdownDescription":"Validate the fragment part of links to headers in other files in Markdown files, for example: `[link](/path/to/file.md#header)`. Inherits the setting value from `#markdown.validate.fragmentLinks.enabled#` by default.","default":"inherit","enum":["inherit","ignore","warning","error"]},"markdown.validate.ignoredLinks":{"type":"array","scope":"resource","markdownDescription":"Configure links that should not be validated. For example adding `/about` would not validate the link `[about](/about)`, while the glob `/assets/**/*.svg` would let you skip validation for any link to `.svg` files under the `assets` directory.","items":{"type":"string"}},"markdown.validate.unusedLinkDefinitions.enabled":{"type":"string","scope":"resource","markdownDescription":"Validate link definitions that are unused in the current file.","default":"hint","enum":["ignore","hint","warning","error"]},"markdown.validate.duplicateLinkDefinitions.enabled":{"type":"string","scope":"resource","markdownDescription":"Validate duplicated definitions in the current file.","default":"warning","enum":["ignore","warning","error"]},"markdown.updateLinksOnFileMove.enabled":{"type":"string","enum":["prompt","always","never"],"markdownEnumDescriptions":["Prompt on each file move.","Always update links automatically.","Never try to update link and don't prompt."],"default":"never","markdownDescription":"Try to update links in Markdown files when a file is renamed/moved in the workspace. Use `#markdown.updateLinksOnFileMove.include#` to configure which files trigger link updates.","scope":"window"},"markdown.updateLinksOnFileMove.include":{"type":"array","markdownDescription":"Glob patterns that specifies files that trigger automatic link updates. See `#markdown.updateLinksOnFileMove.enabled#` for details about this feature.","scope":"window","items":{"type":"string","description":"The glob pattern to match file paths against. Set to true to enable the pattern."},"default":["**/*.{md,mkd,mdwn,mdown,markdown,markdn,mdtxt,mdtext,workbook}","**/*.{jpg,jpe,jpeg,png,bmp,gif,ico,webp,avif,tiff,svg,mp4}"]},"markdown.updateLinksOnFileMove.enableForDirectories":{"type":"boolean","default":true,"description":"Enable updating links when a directory is moved or renamed in the workspace.","scope":"window"},"markdown.occurrencesHighlight.enabled":{"type":"boolean","default":false,"description":"Enable highlighting link occurrences in the current document.","scope":"resource"},"markdown.copyFiles.destination":{"type":"object","markdownDescription":"Configures the path and file name of files created by copy/paste or drag and drop. This is a map of globs that match against a Markdown document path to the destination path where the new file should be created.\n\nThe destination path may use the following variables:\n\n- `${documentDirName}` — Absolute parent directory path of the Markdown document, e.g. `/Users/me/myProject/docs`.\n- `${documentRelativeDirName}` — Relative parent directory path of the Markdown document, e.g. `docs`. This is the same as `${documentDirName}` if the file is not part of a workspace.\n- `${documentFileName}` — The full filename of the Markdown document, e.g. `README.md`.\n- `${documentBaseName}` — The basename of the Markdown document, e.g. `README`.\n- `${documentExtName}` — The extension of the Markdown document, e.g. `md`.\n- `${documentFilePath}` — Absolute path of the Markdown document, e.g. `/Users/me/myProject/docs/README.md`.\n- `${documentRelativeFilePath}` — Relative path of the Markdown document, e.g. `docs/README.md`. This is the same as `${documentFilePath}` if the file is not part of a workspace.\n- `${documentWorkspaceFolder}` — The workspace folder for the Markdown document, e.g. `/Users/me/myProject`. This is the same as `${documentDirName}` if the file is not part of a workspace.\n- `${fileName}` — The file name of the dropped file, e.g. `image.png`.\n- `${fileExtName}` — The extension of the dropped file, e.g. `png`.\n- `${unixTime}` — The current Unix timestamp in milliseconds.\n- `${isoTime}` — The current time in ISO 8601 format, e.g. '2025-06-06T08:40:32.123Z'.","additionalProperties":{"type":"string"}},"markdown.copyFiles.overwriteBehavior":{"type":"string","markdownDescription":"Controls if files created by drop or paste should overwrite existing files.","default":"nameIncrementally","enum":["nameIncrementally","overwrite"],"markdownEnumDescriptions":["If a file with the same name already exists, append a number to the file name, for example: `image.png` becomes `image-1.png`.","If a file with the same name already exists, overwrite it."]},"markdown.preferredMdPathExtensionStyle":{"type":"string","default":"auto","markdownDescription":"Controls if file extensions (for example `.md`) are added or not for links to Markdown files. This setting is used when file paths are added by tooling such as path completions or file renames.","enum":["auto","includeExtension","removeExtension"],"markdownEnumDescriptions":["For existing paths, try to maintain the file extension style. For new paths, add file extensions.","Prefer including the file extension. For example, path completions to a file named `file.md` will insert `file.md`.","Prefer removing the file extension. For example, path completions to a file named `file.md` will insert `file` without the `.md`."]},"markdown.editor.updateLinksOnPaste.enabled":{"type":"boolean","markdownDescription":"Enable/disable a paste option that updates links and reference in text that is copied and pasted between Markdown editors.\n\nTo use this feature, after pasting text that contains updatable links, just click on the Paste Widget and select `Paste and update pasted links`.","scope":"resource","default":true}}},"configurationDefaults":{"[markdown]":{"editor.wordWrap":"on","editor.quickSuggestions":{"comments":"off","strings":"off","other":"off"}}},"jsonValidation":[{"fileMatch":"package.json","url":"./schemas/package.schema.json"}],"markdown.previewStyles":["./media/markdown.css","./media/highlight.css"],"markdown.previewScripts":["./media/index.js"],"customEditors":[{"viewType":"vscode.markdown.preview.editor","displayName":"Markdown Preview","priority":"option","selector":[{"filenamePattern":"*.md"}]}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/markdown-language-features","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.markdown-math"},"manifest":{"name":"markdown-math","displayName":"Markdown Math","description":"Adds math support to Markdown in notebooks.","version":"1.0.0","icon":"icon.png","publisher":"vscode","license":"MIT","aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","engines":{"vscode":"^1.54.0"},"categories":["Other","Programming Languages"],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"main":"./dist/extension","browser":"./dist/browser/extension","activationEvents":[],"contributes":{"languages":[{"id":"markdown-math","aliases":[]}],"grammars":[{"language":"markdown-math","scopeName":"text.html.markdown.math","path":"./syntaxes/md-math.tmLanguage.json"},{"scopeName":"markdown.math.block","path":"./syntaxes/md-math-block.tmLanguage.json","injectTo":["text.html.markdown"],"embeddedLanguages":{"meta.embedded.math.markdown":"latex"}},{"scopeName":"markdown.math.inline","path":"./syntaxes/md-math-inline.tmLanguage.json","injectTo":["text.html.markdown"],"embeddedLanguages":{"meta.embedded.math.markdown":"latex","punctuation.definition.math.end.markdown":"latex"}},{"scopeName":"markdown.math.codeblock","path":"./syntaxes/md-math-fence.tmLanguage.json","injectTo":["text.html.markdown"],"embeddedLanguages":{"meta.embedded.math.markdown":"latex"}}],"notebookRenderer":[{"id":"vscode.markdown-it-katex-extension","displayName":"Markdown it KaTeX renderer","entrypoint":{"extends":"vscode.markdown-it-renderer","path":"./notebook-out/katex.js"}}],"markdown.markdownItPlugins":true,"markdown.previewStyles":["./notebook-out/katex.min.css","./preview-styles/index.css"],"configuration":[{"title":"Markdown Math","properties":{"markdown.math.enabled":{"type":"boolean","default":true,"description":"Enable/disable rendering math in the built-in Markdown preview."},"markdown.math.macros":{"type":"object","additionalProperties":{"type":"string"},"default":{},"description":"A collection of custom macros. Each macro is a key-value pair where the key is a new command name and the value is the expansion of the macro.","scope":"resource"}}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/markdown-math","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.media-preview"},"manifest":{"name":"media-preview","displayName":"Media Preview","description":"Provides VS Code's built-in previews for images, audio, and video","extensionKind":["ui","workspace"],"version":"1.0.0","publisher":"vscode","icon":"icon.png","license":"MIT","aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","engines":{"vscode":"^1.70.0"},"main":"./dist/extension","browser":"./dist/browser/extension.js","categories":["Other"],"activationEvents":[],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"contributes":{"configuration":{"type":"object","title":"Media Previewer","properties":{"mediaPreview.video.autoPlay":{"type":"boolean","default":false,"markdownDescription":"Start playing videos on mute automatically."},"mediaPreview.video.loop":{"type":"boolean","default":false,"markdownDescription":"Loop videos over again automatically."}}},"customEditors":[{"viewType":"imagePreview.previewEditor","displayName":"Image Preview","priority":"builtin","selector":[{"filenamePattern":"*.{jpg,jpe,jpeg,png,bmp,gif,ico,webp,avif,svg}"}]},{"viewType":"vscode.audioPreview","displayName":"Audio Preview","priority":"builtin","selector":[{"filenamePattern":"*.{mp3,wav,ogg,oga}"}]},{"viewType":"vscode.videoPreview","displayName":"Video Preview","priority":"builtin","selector":[{"filenamePattern":"*.{mp4,webm}"}]}],"commands":[{"command":"imagePreview.zoomIn","title":"Zoom in","category":"Image Preview"},{"command":"imagePreview.zoomOut","title":"Zoom out","category":"Image Preview"},{"command":"imagePreview.copyImage","title":"Copy","category":"Image Preview"},{"command":"imagePreview.reopenAsPreview","title":"Reopen as image preview","category":"Image Preview","icon":"$(preview)"},{"command":"imagePreview.reopenAsText","title":"Reopen as source text","category":"Image Preview","icon":"$(go-to-file)"}],"menus":{"commandPalette":[{"command":"imagePreview.zoomIn","when":"activeCustomEditorId == 'imagePreview.previewEditor'","group":"1_imagePreview"},{"command":"imagePreview.zoomOut","when":"activeCustomEditorId == 'imagePreview.previewEditor'","group":"1_imagePreview"},{"command":"imagePreview.copyImage","when":"false"},{"command":"imagePreview.reopenAsPreview","when":"activeEditor == workbench.editors.files.textFileEditor && resourceExtname == '.svg'","group":"navigation"},{"command":"imagePreview.reopenAsText","when":"activeCustomEditorId == 'imagePreview.previewEditor' && resourceExtname == '.svg'","group":"navigation"}],"webview/context":[{"command":"imagePreview.copyImage","when":"webviewId == 'imagePreview.previewEditor'"}],"editor/title":[{"command":"imagePreview.reopenAsPreview","when":"editorFocus && resourceExtname == '.svg'","group":"navigation"},{"command":"imagePreview.reopenAsText","when":"activeCustomEditorId == 'imagePreview.previewEditor' && resourceExtname == '.svg'","group":"navigation"}]}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/media-preview","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.merge-conflict"},"manifest":{"name":"merge-conflict","publisher":"vscode","displayName":"Merge Conflict","description":"Highlighting and commands for inline merge conflicts.","icon":"media/icon.png","version":"1.0.0","license":"MIT","aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","engines":{"vscode":"^1.5.0"},"categories":["Other"],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"activationEvents":["onStartupFinished"],"main":"./dist/mergeConflictMain","browser":"./dist/browser/mergeConflictMain","contributes":{"commands":[{"category":"Merge Conflict","title":"Accept All Current","original":"Accept All Current","command":"merge-conflict.accept.all-current","enablement":"!isMergeEditor"},{"category":"Merge Conflict","title":"Accept All Incoming","original":"Accept All Incoming","command":"merge-conflict.accept.all-incoming","enablement":"!isMergeEditor"},{"category":"Merge Conflict","title":"Accept All Both","original":"Accept All Both","command":"merge-conflict.accept.all-both","enablement":"!isMergeEditor"},{"category":"Merge Conflict","title":"Accept Current","original":"Accept Current","command":"merge-conflict.accept.current","enablement":"!isMergeEditor"},{"category":"Merge Conflict","title":"Accept Incoming","original":"Accept Incoming","command":"merge-conflict.accept.incoming","enablement":"!isMergeEditor"},{"category":"Merge Conflict","title":"Accept Selection","original":"Accept Selection","command":"merge-conflict.accept.selection","enablement":"!isMergeEditor"},{"category":"Merge Conflict","title":"Accept Both","original":"Accept Both","command":"merge-conflict.accept.both","enablement":"!isMergeEditor"},{"category":"Merge Conflict","title":"Next Conflict","original":"Next Conflict","command":"merge-conflict.next","enablement":"!isMergeEditor","icon":"$(arrow-down)"},{"category":"Merge Conflict","title":"Previous Conflict","original":"Previous Conflict","command":"merge-conflict.previous","enablement":"!isMergeEditor","icon":"$(arrow-up)"},{"category":"Merge Conflict","title":"Compare Current Conflict","original":"Compare Current Conflict","command":"merge-conflict.compare","enablement":"!isMergeEditor"}],"menus":{"scm/resourceState/context":[{"command":"merge-conflict.accept.all-current","when":"scmProvider == git && scmResourceGroup == merge","group":"1_modification"},{"command":"merge-conflict.accept.all-incoming","when":"scmProvider == git && scmResourceGroup == merge","group":"1_modification"}],"editor/title":[{"command":"merge-conflict.previous","group":"navigation@1","when":"!isMergeEditor && mergeConflictsCount && mergeConflictsCount != 0"},{"command":"merge-conflict.next","group":"navigation@2","when":"!isMergeEditor && mergeConflictsCount && mergeConflictsCount != 0"}]},"configuration":{"title":"Merge Conflict","properties":{"merge-conflict.codeLens.enabled":{"type":"boolean","description":"Create a CodeLens for merge conflict blocks within editor.","default":true},"merge-conflict.decorators.enabled":{"type":"boolean","description":"Create decorators for merge conflict blocks within editor.","default":true},"merge-conflict.autoNavigateNextConflict.enabled":{"type":"boolean","description":"Whether to automatically navigate to the next merge conflict after resolving a merge conflict.","default":false},"merge-conflict.diffViewPosition":{"type":"string","enum":["Current","Beside","Below"],"description":"Controls where the diff view should be opened when comparing changes in merge conflicts.","enumDescriptions":["Open the diff view in the current editor group.","Open the diff view next to the current editor group.","Open the diff view below the current editor group."],"default":"Current"}}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/merge-conflict","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.mermaid-chat-features"},"manifest":{"name":"mermaid-chat-features","displayName":"Mermaid Chat Features","description":"Adds Mermaid diagram support to built-in chats.","version":"1.0.0","publisher":"vscode","license":"MIT","repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"},"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","engines":{"vscode":"^1.104.0"},"enabledApiProposals":["chatOutputRenderer"],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"main":"./dist/extension","browser":"./dist/browser/extension","activationEvents":["onWebviewPanel:vscode.chat-mermaid-features.preview"],"contributes":{"commands":[{"command":"_mermaid-chat.resetPanZoom","title":"Reset Pan and Zoom"},{"command":"_mermaid-chat.openInEditor","title":"Open Diagram in Editor"},{"command":"_mermaid-chat.copySource","title":"Copy Diagram Source"}],"menus":{"commandPalette":[{"command":"_mermaid-chat.resetPanZoom","when":"false"},{"command":"_mermaid-chat.openInEditor","when":"false"},{"command":"_mermaid-chat.copySource","when":"false"}],"webview/context":[{"command":"_mermaid-chat.resetPanZoom","when":"webviewId == 'vscode.chat-mermaid-features.chatOutputItem'"},{"command":"_mermaid-chat.copySource","when":"webviewId == 'vscode.chat-mermaid-features.chatOutputItem' || webviewId == 'vscode.chat-mermaid-features.preview'"}]},"configuration":{"title":"Mermaid Chat Features","properties":{"mermaid-chat.enabled":{"type":"boolean","default":true,"description":"Enable a tool for experimental Mermaid diagram rendering in chat responses.","scope":"application"}}},"chatOutputRenderers":[{"viewType":"vscode.chat-mermaid-features.chatOutputItem","mimeTypes":["text/vnd.mermaid"]}],"languageModelTools":[{"name":"renderMermaidDiagram","displayName":"Mermaid Renderer","toolReferenceName":"renderMermaidDiagram","canBeReferencedInPrompt":true,"modelDescription":"Renders a Mermaid diagram from Mermaid.js markup.","userDescription":"Render a Mermaid.js diagrams from markup.","when":"config.mermaid-chat.enabled","inputSchema":{"type":"object","properties":{"markup":{"type":"string","description":"The mermaid diagram markup to render as a Mermaid diagram. This should only be the markup of the diagram. Do not include a wrapping code block."},"title":{"type":"string","description":"A short title that describes the diagram."}}}}]}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/mermaid-chat-features","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.microsoft-authentication"},"manifest":{"name":"microsoft-authentication","publisher":"vscode","license":"MIT","displayName":"Microsoft Account","description":"Microsoft authentication provider","version":"0.0.1","engines":{"vscode":"^1.42.0"},"icon":"media/icon.png","categories":["Other"],"activationEvents":[],"enabledApiProposals":["nativeWindowHandle","authIssuers","authenticationChallenges","envIsAppPortable"],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"extensionKind":["ui","workspace"],"contributes":{"authentication":[{"label":"Microsoft","id":"microsoft","authorizationServerGlobs":["https://login.microsoftonline.com/*","https://login.microsoftonline.com/*/v2.0"]},{"label":"Microsoft Sovereign Cloud","id":"microsoft-sovereign-cloud"}],"configuration":[{"title":"Microsoft Sovereign Cloud","properties":{"microsoft-sovereign-cloud.environment":{"type":"string","markdownDescription":"The Sovereign Cloud to use for authentication. If you select `custom`, you must also set the `#microsoft-sovereign-cloud.customEnvironment#` setting.","enum":["ChinaCloud","USGovernment","custom"],"enumDescriptions":["Azure China","Azure US Government","A custom Microsoft Sovereign Cloud"]},"microsoft-sovereign-cloud.customEnvironment":{"type":"object","additionalProperties":true,"markdownDescription":"The custom configuration for the Sovereign Cloud to use with the Microsoft Sovereign Cloud authentication provider. This along with setting `#microsoft-sovereign-cloud.environment#` to `custom` is required to use this feature.","properties":{"name":{"type":"string","description":"The name of the custom Sovereign Cloud."},"portalUrl":{"type":"string","description":"The portal URL for the custom Sovereign Cloud."},"managementEndpointUrl":{"type":"string","description":"The management endpoint for the custom Sovereign Cloud."},"resourceManagerEndpointUrl":{"type":"string","description":"The resource manager endpoint for the custom Sovereign Cloud."},"activeDirectoryEndpointUrl":{"type":"string","description":"The Active Directory endpoint for the custom Sovereign Cloud."},"activeDirectoryResourceId":{"type":"string","description":"The Active Directory resource ID for the custom Sovereign Cloud."}},"required":["name","portalUrl","managementEndpointUrl","resourceManagerEndpointUrl","activeDirectoryEndpointUrl","activeDirectoryResourceId"]}}},{"title":"Microsoft","properties":{"microsoft-authentication.implementation":{"type":"string","default":"msal","enum":["msal","msal-no-broker"],"enumDescriptions":["Use the Microsoft Authentication Library (MSAL) to sign in with a Microsoft account.","Use the Microsoft Authentication Library (MSAL) to sign in with a Microsoft account using a browser. This is useful if you are having issues with the native broker."],"markdownDescription":"The authentication implementation to use for signing in with a Microsoft account.","tags":["onExP"]}}}]},"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","main":"./dist/extension.js","repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/microsoft-authentication","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"ms-vscode.js-debug"},"manifest":{"name":"js-debug","displayName":"JavaScript Debugger","version":"1.105.0","publisher":"ms-vscode","author":{"name":"Microsoft Corporation"},"keywords":["pwa","javascript","node","chrome","debugger"],"description":"An extension for debugging Node.js programs and Chrome.","license":"MIT","engines":{"vscode":"^1.80.0","node":">=10"},"icon":"resources/logo.png","categories":["Debuggers"],"private":true,"repository":{"type":"git","url":"https://github.com/Microsoft/vscode-pwa.git"},"bugs":{"url":"https://github.com/Microsoft/vscode-pwa/issues"},"main":"./src/extension.js","enabledApiProposals":["portsAttributes","workspaceTrust","tunnels"],"extensionKind":["workspace"],"capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":"limited","description":"Trust is required to debug code in this workspace."}},"activationEvents":["onDebugDynamicConfigurations","onDebugInitialConfigurations","onFileSystem:jsDebugNetworkFs","onDebugResolve:pwa-node","onDebugResolve:node-terminal","onDebugResolve:pwa-extensionHost","onDebugResolve:pwa-chrome","onDebugResolve:pwa-msedge","onDebugResolve:node","onDebugResolve:chrome","onDebugResolve:extensionHost","onDebugResolve:msedge","onCommand:extension.js-debug.clearAutoAttachVariables","onCommand:extension.js-debug.setAutoAttachVariables","onCommand:extension.js-debug.autoAttachToProcess","onCommand:extension.js-debug.pickNodeProcess","onCommand:extension.js-debug.requestCDPProxy","onCommand:extension.js-debug.completion.nodeTool"],"contributes":{"menus":{"commandPalette":[{"command":"extension.js-debug.prettyPrint","title":"Pretty print for debugging","when":"debugType == pwa-extensionHost && debugState == stopped || debugType == node-terminal && debugState == stopped || debugType == pwa-node && debugState == stopped || debugType == pwa-chrome && debugState == stopped || debugType == pwa-msedge && debugState == stopped"},{"command":"extension.js-debug.startProfile","title":"Take Performance Profile","when":"debugType == pwa-extensionHost && inDebugMode && !jsDebugIsProfiling || debugType == node-terminal && inDebugMode && !jsDebugIsProfiling || debugType == pwa-node && inDebugMode && !jsDebugIsProfiling || debugType == pwa-chrome && inDebugMode && !jsDebugIsProfiling || debugType == pwa-msedge && inDebugMode && !jsDebugIsProfiling"},{"command":"extension.js-debug.stopProfile","title":"Stop Performance Profile","when":"debugType == pwa-extensionHost && inDebugMode && jsDebugIsProfiling || debugType == node-terminal && inDebugMode && jsDebugIsProfiling || debugType == pwa-node && inDebugMode && jsDebugIsProfiling || debugType == pwa-chrome && inDebugMode && jsDebugIsProfiling || debugType == pwa-msedge && inDebugMode && jsDebugIsProfiling"},{"command":"extension.js-debug.revealPage","when":"false"},{"command":"extension.js-debug.debugLink","title":"Open Link","when":"!isWeb"},{"command":"extension.js-debug.createDiagnostics","title":"Diagnose Breakpoint Problems","when":"debugType == pwa-extensionHost && inDebugMode || debugType == node-terminal && inDebugMode || debugType == pwa-node && inDebugMode || debugType == pwa-chrome && inDebugMode || debugType == pwa-msedge && inDebugMode"},{"command":"extension.js-debug.getDiagnosticLogs","title":"Save Diagnostic JS Debug Logs","when":"debugType == pwa-extensionHost && inDebugMode || debugType == node-terminal && inDebugMode || debugType == pwa-node && inDebugMode || debugType == pwa-chrome && inDebugMode || debugType == pwa-msedge && inDebugMode"},{"command":"extension.js-debug.openEdgeDevTools","title":"Open Browser Devtools","when":"debugType == pwa-msedge"},{"command":"extension.js-debug.callers.add","title":"Exclude caller from pausing in the current location","when":"debugType == pwa-extensionHost && debugState == \"stopped\" || debugType == node-terminal && debugState == \"stopped\" || debugType == pwa-node && debugState == \"stopped\" || debugType == pwa-chrome && debugState == \"stopped\" || debugType == pwa-msedge && debugState == \"stopped\""},{"command":"extension.js-debug.callers.goToCaller","when":"false"},{"command":"extension.js-debug.callers.gotToTarget","when":"false"},{"command":"extension.js-debug.network.copyUri","when":"false"},{"command":"extension.js-debug.network.openBody","when":"false"},{"command":"extension.js-debug.network.openBodyInHex","when":"false"},{"command":"extension.js-debug.network.replayXHR","when":"false"},{"command":"extension.js-debug.network.viewRequest","when":"false"},{"command":"extension.js-debug.network.clear","when":"false"},{"command":"extension.js-debug.enableSourceMapStepping","when":"jsDebugIsMapSteppingDisabled"},{"command":"extension.js-debug.disableSourceMapStepping","when":"!jsDebugIsMapSteppingDisabled"}],"debug/callstack/context":[{"command":"extension.js-debug.revealPage","group":"navigation","when":"debugType == pwa-chrome && callStackItemType == 'session' || debugType == pwa-msedge && callStackItemType == 'session'"},{"command":"extension.js-debug.toggleSkippingFile","group":"navigation","when":"debugType == pwa-extensionHost && callStackItemType == 'session' || debugType == node-terminal && callStackItemType == 'session' || debugType == pwa-node && callStackItemType == 'session' || debugType == pwa-chrome && callStackItemType == 'session' || debugType == pwa-msedge && callStackItemType == 'session'"},{"command":"extension.js-debug.startProfile","group":"navigation","when":"debugType == pwa-extensionHost && !jsDebugIsProfiling && callStackItemType == 'session' || debugType == node-terminal && !jsDebugIsProfiling && callStackItemType == 'session' || debugType == pwa-node && !jsDebugIsProfiling && callStackItemType == 'session' || debugType == pwa-chrome && !jsDebugIsProfiling && callStackItemType == 'session' || debugType == pwa-msedge && !jsDebugIsProfiling && callStackItemType == 'session'"},{"command":"extension.js-debug.stopProfile","group":"navigation","when":"debugType == pwa-extensionHost && jsDebugIsProfiling && callStackItemType == 'session' || debugType == node-terminal && jsDebugIsProfiling && callStackItemType == 'session' || debugType == pwa-node && jsDebugIsProfiling && callStackItemType == 'session' || debugType == pwa-chrome && jsDebugIsProfiling && callStackItemType == 'session' || debugType == pwa-msedge && jsDebugIsProfiling && callStackItemType == 'session'"},{"command":"extension.js-debug.startProfile","group":"inline","when":"debugType == pwa-extensionHost && !jsDebugIsProfiling || debugType == node-terminal && !jsDebugIsProfiling || debugType == pwa-node && !jsDebugIsProfiling || debugType == pwa-chrome && !jsDebugIsProfiling || debugType == pwa-msedge && !jsDebugIsProfiling"},{"command":"extension.js-debug.stopProfile","group":"inline","when":"debugType == pwa-extensionHost && jsDebugIsProfiling || debugType == node-terminal && jsDebugIsProfiling || debugType == pwa-node && jsDebugIsProfiling || debugType == pwa-chrome && jsDebugIsProfiling || debugType == pwa-msedge && jsDebugIsProfiling"},{"command":"extension.js-debug.callers.add","when":"debugType == pwa-extensionHost && callStackItemType == 'stackFrame' || debugType == node-terminal && callStackItemType == 'stackFrame' || debugType == pwa-node && callStackItemType == 'stackFrame' || debugType == pwa-chrome && callStackItemType == 'stackFrame' || debugType == pwa-msedge && callStackItemType == 'stackFrame'"}],"debug/toolBar":[{"command":"extension.js-debug.stopProfile","when":"debugType == pwa-extensionHost && jsDebugIsProfiling || debugType == node-terminal && jsDebugIsProfiling || debugType == pwa-node && jsDebugIsProfiling || debugType == pwa-chrome && jsDebugIsProfiling || debugType == pwa-msedge && jsDebugIsProfiling"},{"command":"extension.js-debug.openEdgeDevTools","when":"debugType == pwa-msedge"},{"command":"extension.js-debug.enableSourceMapStepping","when":"jsDebugIsMapSteppingDisabled"}],"view/title":[{"command":"extension.js-debug.addCustomBreakpoints","when":"view == jsBrowserBreakpoints","group":"navigation"},{"command":"extension.js-debug.removeAllCustomBreakpoints","when":"view == jsBrowserBreakpoints","group":"navigation"},{"command":"extension.js-debug.callers.removeAll","group":"navigation","when":"view == jsExcludedCallers"},{"command":"extension.js-debug.disableSourceMapStepping","group":"navigation","when":"debugType == pwa-extensionHost && view == workbench.debug.callStackView && !jsDebugIsMapSteppingDisabled || debugType == node-terminal && view == workbench.debug.callStackView && !jsDebugIsMapSteppingDisabled || debugType == pwa-node && view == workbench.debug.callStackView && !jsDebugIsMapSteppingDisabled || debugType == pwa-chrome && view == workbench.debug.callStackView && !jsDebugIsMapSteppingDisabled || debugType == pwa-msedge && view == workbench.debug.callStackView && !jsDebugIsMapSteppingDisabled"},{"command":"extension.js-debug.enableSourceMapStepping","group":"navigation","when":"debugType == pwa-extensionHost && view == workbench.debug.callStackView && jsDebugIsMapSteppingDisabled || debugType == node-terminal && view == workbench.debug.callStackView && jsDebugIsMapSteppingDisabled || debugType == pwa-node && view == workbench.debug.callStackView && jsDebugIsMapSteppingDisabled || debugType == pwa-chrome && view == workbench.debug.callStackView && jsDebugIsMapSteppingDisabled || debugType == pwa-msedge && view == workbench.debug.callStackView && jsDebugIsMapSteppingDisabled"},{"command":"extension.js-debug.network.clear","group":"navigation","when":"view == jsDebugNetworkTree"}],"view/item/context":[{"command":"extension.js-debug.addXHRBreakpoints","when":"view == jsBrowserBreakpoints && viewItem == xhrBreakpoint"},{"command":"extension.js-debug.editXHRBreakpoints","when":"view == jsBrowserBreakpoints && viewItem == xhrBreakpoint","group":"inline"},{"command":"extension.js-debug.editXHRBreakpoints","when":"view == jsBrowserBreakpoints && viewItem == xhrBreakpoint"},{"command":"extension.js-debug.removeXHRBreakpoint","when":"view == jsBrowserBreakpoints && viewItem == xhrBreakpoint","group":"inline"},{"command":"extension.js-debug.removeXHRBreakpoint","when":"view == jsBrowserBreakpoints && viewItem == xhrBreakpoint"},{"command":"extension.js-debug.addXHRBreakpoints","when":"view == jsBrowserBreakpoints && viewItem == xhrCategory","group":"inline"},{"command":"extension.js-debug.callers.goToCaller","group":"inline","when":"view == jsExcludedCallers"},{"command":"extension.js-debug.callers.gotToTarget","group":"inline","when":"view == jsExcludedCallers"},{"command":"extension.js-debug.callers.remove","group":"inline","when":"view == jsExcludedCallers"},{"command":"extension.js-debug.network.viewRequest","group":"inline@1","when":"view == jsDebugNetworkTree"},{"command":"extension.js-debug.network.openBody","group":"body@1","when":"view == jsDebugNetworkTree"},{"command":"extension.js-debug.network.openBodyInHex","group":"body@2","when":"view == jsDebugNetworkTree"},{"command":"extension.js-debug.network.copyUri","group":"other@1","when":"view == jsDebugNetworkTree"},{"command":"extension.js-debug.network.replayXHR","group":"other@2","when":"view == jsDebugNetworkTree"}],"editor/title":[{"command":"extension.js-debug.prettyPrint","group":"navigation","when":"jsDebugCanPrettyPrint"}]},"breakpoints":[{"language":"javascript"},{"language":"typescript"},{"language":"typescriptreact"},{"language":"javascriptreact"},{"language":"fsharp"},{"language":"html"},{"language":"wat"},{"language":"c"},{"language":"cpp"},{"language":"rust"},{"language":"zig"}],"debuggers":[{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"attach":{"properties":{"address":{"default":"localhost","description":"TCP/IP address of process to be debugged. Default is 'localhost'.","type":"string"},"attachExistingChildren":{"default":false,"description":"Whether to attempt to attach to already-spawned child processes.","type":"boolean"},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"continueOnAttach":{"default":true,"markdownDescription":"If true, we'll automatically resume programs launched and waiting on `--inspect-brk`","type":"boolean"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"port":{"default":9229,"description":"Debug port to attach to. Default is 9229.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}],"tags":["setup"]},"processId":{"default":"${command:PickProcess}","description":"ID of process to attach to.","type":"string"},"remoteHostHeader":{"description":"Explicit Host header to use when connecting to the websocket of inspector. If unspecified, the host header will be set to 'localhost'. This is useful when the inspector is running behind a proxy that only accept particular Host header.","type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"websocketAddress":{"description":"Exact websocket address to attach to. If unspecified, it will be discovered from the address and port.","type":"string"}}},"launch":{"properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}}}},"configurationSnippets":[],"deprecated":"Please use type node instead","label":"Node.js","languages":["javascript","typescript","javascriptreact","typescriptreact"],"strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"pwa-node","variables":{"PickProcess":"extension.js-debug.pickNodeProcess"}},{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"attach":{"properties":{"address":{"default":"localhost","description":"TCP/IP address of process to be debugged. Default is 'localhost'.","type":"string"},"attachExistingChildren":{"default":false,"description":"Whether to attempt to attach to already-spawned child processes.","type":"boolean"},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"continueOnAttach":{"default":true,"markdownDescription":"If true, we'll automatically resume programs launched and waiting on `--inspect-brk`","type":"boolean"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"port":{"default":9229,"description":"Debug port to attach to. Default is 9229.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}],"tags":["setup"]},"processId":{"default":"${command:PickProcess}","description":"ID of process to attach to.","type":"string"},"remoteHostHeader":{"description":"Explicit Host header to use when connecting to the websocket of inspector. If unspecified, the host header will be set to 'localhost'. This is useful when the inspector is running behind a proxy that only accept particular Host header.","type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"websocketAddress":{"description":"Exact websocket address to attach to. If unspecified, it will be discovered from the address and port.","type":"string"}}},"launch":{"properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}}}},"configurationSnippets":[{"body":{"name":"${1:Attach}","port":9229,"request":"attach","skipFiles":["/**"],"type":"node"},"description":"Attach to a running node program","label":"Node.js: Attach"},{"body":{"address":"${2:TCP/IP address of process to be debugged}","localRoot":"^\"\\${workspaceFolder}\"","name":"${1:Attach to Remote}","port":9229,"remoteRoot":"${3:Absolute path to the remote directory containing the program}","request":"attach","skipFiles":["/**"],"type":"node"},"description":"Attach to the debug port of a remote node program","label":"Node.js: Attach to Remote Program"},{"body":{"name":"${1:Attach by Process ID}","processId":"^\"\\${command:PickProcess}\"","request":"attach","skipFiles":["/**"],"type":"node"},"description":"Open process picker to select node process to attach to","label":"Node.js: Attach to Process"},{"body":{"name":"${2:Launch Program}","program":"^\"\\${workspaceFolder}/${1:app.js}\"","request":"launch","skipFiles":["/**"],"type":"node"},"description":"Launch a node program in debug mode","label":"Node.js: Launch Program"},{"body":{"name":"${1:Launch via NPM}","request":"launch","runtimeArgs":["run-script","debug"],"runtimeExecutable":"npm","skipFiles":["/**"],"type":"node"},"label":"Node.js: Launch via npm","markdownDescription":"Launch a node program through an npm `debug` script"},{"body":{"console":"integratedTerminal","internalConsoleOptions":"neverOpen","name":"nodemon","program":"^\"\\${workspaceFolder}/${1:app.js}\"","request":"launch","restart":true,"runtimeExecutable":"nodemon","skipFiles":["/**"],"type":"node"},"description":"Use nodemon to relaunch a debug session on source changes","label":"Node.js: Nodemon Setup"},{"body":{"args":["-u","tdd","--timeout","999999","--colors","^\"\\${workspaceFolder}/${1:test}\""],"internalConsoleOptions":"openOnSessionStart","name":"Mocha Tests","program":"^\"mocha\"","request":"launch","skipFiles":["/**"],"type":"node"},"description":"Debug mocha tests","label":"Node.js: Mocha Tests"},{"body":{"args":["${1:generator}"],"console":"integratedTerminal","internalConsoleOptions":"neverOpen","name":"Yeoman ${1:generator}","program":"^\"\\${workspaceFolder}/node_modules/yo/lib/cli.js\"","request":"launch","skipFiles":["/**"],"type":"node"},"label":"Node.js: Yeoman generator","markdownDescription":"Debug yeoman generator (install by running `npm link` in project folder)"},{"body":{"args":["${1:task}"],"name":"Gulp ${1:task}","program":"^\"\\${workspaceFolder}/node_modules/gulp/bin/gulp.js\"","request":"launch","skipFiles":["/**"],"type":"node"},"description":"Debug gulp task (make sure to have a local gulp installed in your project)","label":"Node.js: Gulp task"},{"body":{"name":"Electron Main","program":"^\"\\${workspaceFolder}/main.js\"","request":"launch","runtimeExecutable":"^\"electron\"","skipFiles":["/**"],"type":"node"},"description":"Debug the Electron main process","label":"Node.js: Electron Main"}],"label":"Node.js","strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"node","variables":{"PickProcess":"extension.js-debug.pickNodeProcess"}},{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"launch":{"properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}}}},"configurationSnippets":[{"body":{"command":"npm start","name":"Run npm start","request":"launch","type":"node-terminal"},"description":"Run \"npm start\" in a debug terminal","label":"Run \"npm start\" in a debug terminal"}],"label":"JavaScript Debug Terminal","languages":[],"strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"node-terminal"},{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"launch":{"properties":{"args":{"default":["--extensionDevelopmentPath=${workspaceFolder}"],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":"array"},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"debugWebWorkerHost":{"default":true,"markdownDescription":"Configures whether we should try to attach to the web worker extension host.","type":["boolean"]},"debugWebviews":{"default":true,"markdownDescription":"Configures whether we should try to attach to webviews in the launched VS Code instance. This will only work in desktop VS Code.","type":["boolean"]},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"rendererDebugOptions":{"default":{"webRoot":"${workspaceFolder}"},"markdownDescription":"Chrome launch options used when attaching to the renderer process, with `debugWebviews` or `debugWebWorkerHost`.","properties":{"address":{"default":"localhost","description":"IP address or hostname the debugged browser is listening on.","type":"string"},"browserAttachLocation":{"default":null,"description":"Forces the browser to attach in one location. In a remote workspace (through ssh or WSL, for example) this can be used to attach to a browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":9229,"description":"Port to use to remote debugging the browser, given as `--remote-debugging-port` when launching the browser.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}],"tags":["setup"]},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":false,"markdownDescription":"Whether to reconnect if the browser connection is closed","type":"boolean"},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"targetSelection":{"default":"automatic","enum":["pick","automatic"],"markdownDescription":"Whether to attach to all targets that match the URL filter (\"automatic\") or ask to pick one (\"pick\").","type":"string"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}},"type":"object"},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeExecutable":{"default":"node","markdownDescription":"Absolute path to VS Code.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"testConfiguration":{"default":"${workspaceFolder}/.vscode-test.js","markdownDescription":"Path to a test configuration file for the [test CLI](https://code.visualstudio.com/api/working-with-extensions/testing-extension#quick-setup-the-test-cli).","type":"string"},"testConfigurationLabel":{"default":"","markdownDescription":"A single configuration to run from the file. If not specified, you may be asked to pick.","type":"string"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"required":[]}},"configurationSnippets":[],"deprecated":"Please use type extensionHost instead","label":"VS Code Extension Development","languages":["javascript","typescript","javascriptreact","typescriptreact"],"strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"pwa-extensionHost"},{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"launch":{"properties":{"args":{"default":["--extensionDevelopmentPath=${workspaceFolder}"],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":"array"},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"debugWebWorkerHost":{"default":true,"markdownDescription":"Configures whether we should try to attach to the web worker extension host.","type":["boolean"]},"debugWebviews":{"default":true,"markdownDescription":"Configures whether we should try to attach to webviews in the launched VS Code instance. This will only work in desktop VS Code.","type":["boolean"]},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"rendererDebugOptions":{"default":{"webRoot":"${workspaceFolder}"},"markdownDescription":"Chrome launch options used when attaching to the renderer process, with `debugWebviews` or `debugWebWorkerHost`.","properties":{"address":{"default":"localhost","description":"IP address or hostname the debugged browser is listening on.","type":"string"},"browserAttachLocation":{"default":null,"description":"Forces the browser to attach in one location. In a remote workspace (through ssh or WSL, for example) this can be used to attach to a browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":9229,"description":"Port to use to remote debugging the browser, given as `--remote-debugging-port` when launching the browser.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}],"tags":["setup"]},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":false,"markdownDescription":"Whether to reconnect if the browser connection is closed","type":"boolean"},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"targetSelection":{"default":"automatic","enum":["pick","automatic"],"markdownDescription":"Whether to attach to all targets that match the URL filter (\"automatic\") or ask to pick one (\"pick\").","type":"string"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}},"type":"object"},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeExecutable":{"default":"node","markdownDescription":"Absolute path to VS Code.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"testConfiguration":{"default":"${workspaceFolder}/.vscode-test.js","markdownDescription":"Path to a test configuration file for the [test CLI](https://code.visualstudio.com/api/working-with-extensions/testing-extension#quick-setup-the-test-cli).","type":"string"},"testConfigurationLabel":{"default":"","markdownDescription":"A single configuration to run from the file. If not specified, you may be asked to pick.","type":"string"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"required":[]}},"configurationSnippets":[{"body":{"args":["^\"--extensionDevelopmentPath=\\${workspaceFolder}\""],"name":"Launch Extension","outFiles":["^\"\\${workspaceFolder}/out/**/*.js\""],"preLaunchTask":"npm","request":"launch","type":"extensionHost"},"description":"Launch a VS Code extension in debug mode","label":"VS Code Extension Development"}],"label":"VS Code Extension Development","strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"extensionHost"},{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"attach":{"properties":{"address":{"default":"localhost","description":"IP address or hostname the debugged browser is listening on.","type":"string"},"browserAttachLocation":{"default":null,"description":"Forces the browser to attach in one location. In a remote workspace (through ssh or WSL, for example) this can be used to attach to a browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":9229,"description":"Port to use to remote debugging the browser, given as `--remote-debugging-port` when launching the browser.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}],"tags":["setup"]},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":false,"markdownDescription":"Whether to reconnect if the browser connection is closed","type":"boolean"},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"targetSelection":{"default":"automatic","enum":["pick","automatic"],"markdownDescription":"Whether to attach to all targets that match the URL filter (\"automatic\") or ask to pick one (\"pick\").","type":"string"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}}},"launch":{"properties":{"browserLaunchLocation":{"default":null,"description":"Forces the browser to be launched in one location. In a remote workspace (through ssh or WSL, for example) this can be used to open the browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"cleanUp":{"default":"wholeBrowser","description":"What clean-up to do after the debugging session finishes. Close only the tab being debug, vs. close the whole browser.","enum":["wholeBrowser","onlyTab"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":null,"description":"Optional working directory for the runtime executable.","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"default":{},"description":"Optional dictionary of environment key/value pairs for the browser.","type":"object"},"file":{"default":"${workspaceFolder}/index.html","description":"A local html file to open in the browser","tags":["setup"],"type":"string"},"includeDefaultArgs":{"default":true,"description":"Whether default browser launch arguments (to disable features that may make debugging harder) will be included in the launch.","type":"boolean"},"includeLaunchArgs":{"default":true,"description":"Advanced: whether any default launch/debugging arguments are set on the browser. The debugger will assume the browser will use pipe debugging such as that which is provided with `--remote-debugging-pipe`.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how browser processes are killed when stopping the session with `cleanUp: wholeBrowser`. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":0,"description":"Port for the browser to listen on. Defaults to \"0\", which will cause the browser to be debugged via pipes, which is generally more secure and should be chosen unless you need to attach to the browser from another tool.","type":"number"},"profileStartup":{"default":true,"description":"If true, will start profiling soon as the process launches","type":"boolean"},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"type":"array"},"runtimeExecutable":{"default":"stable","description":"Either 'canary', 'stable', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or CHROME_PATH environment variable.","type":["string","null"]},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"userDataDir":{"default":true,"description":"By default, the browser is launched with a separate user profile in a temp folder. Use this option to override it. Set to false to launch with your default user profile. A new browser can't be launched if an instance is already running from `userDataDir`.","type":["string","boolean"]},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}}}},"configurationSnippets":[],"deprecated":"Please use type chrome instead","label":"Web App (Chrome)","languages":["javascript","typescript","javascriptreact","typescriptreact","html","css","coffeescript","handlebars","vue"],"strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"pwa-chrome"},{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"attach":{"properties":{"address":{"default":"localhost","description":"IP address or hostname the debugged browser is listening on.","type":"string"},"browserAttachLocation":{"default":null,"description":"Forces the browser to attach in one location. In a remote workspace (through ssh or WSL, for example) this can be used to attach to a browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":9229,"description":"Port to use to remote debugging the browser, given as `--remote-debugging-port` when launching the browser.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}],"tags":["setup"]},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":false,"markdownDescription":"Whether to reconnect if the browser connection is closed","type":"boolean"},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"targetSelection":{"default":"automatic","enum":["pick","automatic"],"markdownDescription":"Whether to attach to all targets that match the URL filter (\"automatic\") or ask to pick one (\"pick\").","type":"string"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}}},"launch":{"properties":{"browserLaunchLocation":{"default":null,"description":"Forces the browser to be launched in one location. In a remote workspace (through ssh or WSL, for example) this can be used to open the browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"cleanUp":{"default":"wholeBrowser","description":"What clean-up to do after the debugging session finishes. Close only the tab being debug, vs. close the whole browser.","enum":["wholeBrowser","onlyTab"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":null,"description":"Optional working directory for the runtime executable.","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"default":{},"description":"Optional dictionary of environment key/value pairs for the browser.","type":"object"},"file":{"default":"${workspaceFolder}/index.html","description":"A local html file to open in the browser","tags":["setup"],"type":"string"},"includeDefaultArgs":{"default":true,"description":"Whether default browser launch arguments (to disable features that may make debugging harder) will be included in the launch.","type":"boolean"},"includeLaunchArgs":{"default":true,"description":"Advanced: whether any default launch/debugging arguments are set on the browser. The debugger will assume the browser will use pipe debugging such as that which is provided with `--remote-debugging-pipe`.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how browser processes are killed when stopping the session with `cleanUp: wholeBrowser`. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":0,"description":"Port for the browser to listen on. Defaults to \"0\", which will cause the browser to be debugged via pipes, which is generally more secure and should be chosen unless you need to attach to the browser from another tool.","type":"number"},"profileStartup":{"default":true,"description":"If true, will start profiling soon as the process launches","type":"boolean"},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"type":"array"},"runtimeExecutable":{"default":"stable","description":"Either 'canary', 'stable', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or CHROME_PATH environment variable.","type":["string","null"]},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"userDataDir":{"default":true,"description":"By default, the browser is launched with a separate user profile in a temp folder. Use this option to override it. Set to false to launch with your default user profile. A new browser can't be launched if an instance is already running from `userDataDir`.","type":["string","boolean"]},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}}}},"configurationSnippets":[{"body":{"name":"Launch Chrome","request":"launch","type":"chrome","url":"http://localhost:8080","webRoot":"^\"${2:\\${workspaceFolder\\}}\""},"description":"Launch Chrome to debug a URL","label":"Chrome: Launch"},{"body":{"name":"Attach to Chrome","port":9222,"request":"attach","type":"chrome","webRoot":"^\"${2:\\${workspaceFolder\\}}\""},"description":"Attach to an instance of Chrome already in debug mode","label":"Chrome: Attach"}],"label":"Web App (Chrome)","strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"chrome"},{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"attach":{"properties":{"address":{"default":"localhost","description":"IP address or hostname the debugged browser is listening on.","type":"string"},"browserAttachLocation":{"default":null,"description":"Forces the browser to attach in one location. In a remote workspace (through ssh or WSL, for example) this can be used to attach to a browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":9229,"description":"Port to use to remote debugging the browser, given as `--remote-debugging-port` when launching the browser.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}],"tags":["setup"]},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":false,"markdownDescription":"Whether to reconnect if the browser connection is closed","type":"boolean"},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"targetSelection":{"default":"automatic","enum":["pick","automatic"],"markdownDescription":"Whether to attach to all targets that match the URL filter (\"automatic\") or ask to pick one (\"pick\").","type":"string"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"useWebView":{"default":{"pipeName":"MyPipeName"},"description":"An object containing the `pipeName` of a debug pipe for a UWP hosted Webview2. This is the \"MyTestSharedMemory\" when creating the pipe \"\\\\.\\pipe\\LOCAL\\MyTestSharedMemory\"","properties":{"pipeName":{"type":"string"}},"type":"object"},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}}},"launch":{"properties":{"address":{"default":"localhost","description":"When debugging webviews, the IP address or hostname the webview is listening on. Will be automatically discovered if not set.","type":"string"},"browserLaunchLocation":{"default":null,"description":"Forces the browser to be launched in one location. In a remote workspace (through ssh or WSL, for example) this can be used to open the browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"cleanUp":{"default":"wholeBrowser","description":"What clean-up to do after the debugging session finishes. Close only the tab being debug, vs. close the whole browser.","enum":["wholeBrowser","onlyTab"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":null,"description":"Optional working directory for the runtime executable.","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"default":{},"description":"Optional dictionary of environment key/value pairs for the browser.","type":"object"},"file":{"default":"${workspaceFolder}/index.html","description":"A local html file to open in the browser","tags":["setup"],"type":"string"},"includeDefaultArgs":{"default":true,"description":"Whether default browser launch arguments (to disable features that may make debugging harder) will be included in the launch.","type":"boolean"},"includeLaunchArgs":{"default":true,"description":"Advanced: whether any default launch/debugging arguments are set on the browser. The debugger will assume the browser will use pipe debugging such as that which is provided with `--remote-debugging-pipe`.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how browser processes are killed when stopping the session with `cleanUp: wholeBrowser`. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":9229,"description":"When debugging webviews, the port the webview debugger is listening on. Will be automatically discovered if not set.","type":"number"},"profileStartup":{"default":true,"description":"If true, will start profiling soon as the process launches","type":"boolean"},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"type":"array"},"runtimeExecutable":{"default":"stable","description":"Either 'canary', 'stable', 'dev', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or EDGE_PATH environment variable.","type":["string","null"]},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"useWebView":{"default":false,"description":"When 'true', the debugger will treat the runtime executable as a host application that contains a WebView allowing you to debug the WebView script content.","type":"boolean"},"userDataDir":{"default":true,"description":"By default, the browser is launched with a separate user profile in a temp folder. Use this option to override it. Set to false to launch with your default user profile. A new browser can't be launched if an instance is already running from `userDataDir`.","type":["string","boolean"]},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}}}},"configurationSnippets":[],"deprecated":"Please use type msedge instead","label":"Web App (Edge)","languages":["javascript","typescript","javascriptreact","typescriptreact","html","css","coffeescript","handlebars","vue"],"strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"pwa-msedge"},{"aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","configurationAttributes":{"attach":{"properties":{"address":{"default":"localhost","description":"IP address or hostname the debugged browser is listening on.","type":"string"},"browserAttachLocation":{"default":null,"description":"Forces the browser to attach in one location. In a remote workspace (through ssh or WSL, for example) this can be used to attach to a browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":9229,"description":"Port to use to remote debugging the browser, given as `--remote-debugging-port` when launching the browser.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}],"tags":["setup"]},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":false,"markdownDescription":"Whether to reconnect if the browser connection is closed","type":"boolean"},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"targetSelection":{"default":"automatic","enum":["pick","automatic"],"markdownDescription":"Whether to attach to all targets that match the URL filter (\"automatic\") or ask to pick one (\"pick\").","type":"string"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"useWebView":{"default":{"pipeName":"MyPipeName"},"description":"An object containing the `pipeName` of a debug pipe for a UWP hosted Webview2. This is the \"MyTestSharedMemory\" when creating the pipe \"\\\\.\\pipe\\LOCAL\\MyTestSharedMemory\"","properties":{"pipeName":{"type":"string"}},"type":"object"},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}}},"launch":{"properties":{"address":{"default":"localhost","description":"When debugging webviews, the IP address or hostname the webview is listening on. Will be automatically discovered if not set.","type":"string"},"browserLaunchLocation":{"default":null,"description":"Forces the browser to be launched in one location. In a remote workspace (through ssh or WSL, for example) this can be used to open the browser on the remote machine rather than locally.","oneOf":[{"type":"null"},{"enum":["ui","workspace"],"type":"string"}]},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"cleanUp":{"default":"wholeBrowser","description":"What clean-up to do after the debugging session finishes. Close only the tab being debug, vs. close the whole browser.","enum":["wholeBrowser","onlyTab"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":null,"description":"Optional working directory for the runtime executable.","type":"string"},"disableNetworkCache":{"default":true,"description":"Controls whether to skip the network cache for each request","type":"boolean"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"default":{},"description":"Optional dictionary of environment key/value pairs for the browser.","type":"object"},"file":{"default":"${workspaceFolder}/index.html","description":"A local html file to open in the browser","tags":["setup"],"type":"string"},"includeDefaultArgs":{"default":true,"description":"Whether default browser launch arguments (to disable features that may make debugging harder) will be included in the launch.","type":"boolean"},"includeLaunchArgs":{"default":true,"description":"Advanced: whether any default launch/debugging arguments are set on the browser. The debugger will assume the browser will use pipe debugging such as that which is provided with `--remote-debugging-pipe`.","type":"boolean"},"inspectUri":{"default":null,"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","type":["string","null"]},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how browser processes are killed when stopping the session with `cleanUp: wholeBrowser`. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pathMapping":{"default":{},"description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","type":"object"},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"perScriptSourcemaps":{"default":"auto","description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate.","enum":["yes","no","auto"],"type":"string"},"port":{"default":9229,"description":"When debugging webviews, the port the webview debugger is listening on. Will be automatically discovered if not set.","type":"number"},"profileStartup":{"default":true,"description":"If true, will start profiling soon as the process launches","type":"boolean"},"resolveSourceMapLocations":{"default":null,"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"type":"array"},"runtimeExecutable":{"default":"stable","description":"Either 'canary', 'stable', 'dev', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or EDGE_PATH environment variable.","type":["string","null"]},"server":{"oneOf":[{"additionalProperties":false,"default":{"program":"node my-server.js"},"description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","properties":{"args":{"default":[],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"tags":["setup"],"type":["array","string"]},"attachSimplePort":{"default":9229,"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","oneOf":[{"type":"integer"},{"pattern":"^\\${.*}$","type":"string"}]},"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"console":{"default":"internalConsole","description":"Where to launch the debug target.","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"type":"string"},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"experimentalNetworking":{"default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"],"type":"string"},"killBehavior":{"default":"forceful","enum":["forceful","polite","none"],"markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"profileStartup":{"default":true,"description":"If true, will start profiling as soon as the process launches","type":"boolean"},"program":{"default":"","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","tags":["setup"],"type":"string"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"restart":{"default":true,"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","oneOf":[{"type":"boolean"},{"properties":{"delay":{"default":1000,"minimum":0,"type":"number"},"maxAttempts":{"default":10,"minimum":0,"type":"number"}},"type":"object"}]},"runtimeArgs":{"default":[],"description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"tags":["setup"],"type":"array"},"runtimeExecutable":{"default":"node","markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","type":["string","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"runtimeVersion":{"default":"default","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","type":"string"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"stopOnEntry":{"default":true,"description":"Automatically stop program after launch.","type":["boolean","string"]},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"},{"additionalProperties":false,"default":{"program":"npm start"},"description":"JavaScript Debug Terminal","properties":{"autoAttachChildProcesses":{"default":true,"description":"Attach debugger to new child processes automatically.","type":"boolean"},"cascadeTerminateToConfigurations":{"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped.","items":{"type":"string","uniqueItems":true},"type":"array"},"command":{"default":"npm start","description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","tags":["setup"],"type":["string","null"]},"customDescriptionGenerator":{"description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n ","type":"string"},"customPropertiesGenerator":{"deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181","type":"string"},"cwd":{"default":"${workspaceFolder}","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"],"type":"string"},"enableContentValidation":{"default":true,"description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.","type":"boolean"},"enableDWARF":{"default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function.","type":"boolean"},"env":{"additionalProperties":{"type":["string","null"]},"default":{},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","tags":["setup"],"type":"object"},"envFile":{"default":"${workspaceFolder}/.env","description":"Absolute path to a file containing environment variable definitions.","type":"string"},"localRoot":{"default":null,"description":"Path to the local directory containing the program.","type":["string","null"]},"nodeVersionHint":{"default":12,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","minimum":8,"type":"number"},"outFiles":{"default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","items":{"type":"string"},"tags":["setup"],"type":["array"]},"outputCapture":{"default":"console","enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`."},"pauseForSourceMap":{"default":false,"markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","type":"boolean"},"remoteRoot":{"default":null,"description":"Absolute path to the remote directory containing the program.","type":["string","null"]},"resolveSourceMapLocations":{"default":["${workspaceFolder}/**","!**/node_modules/**"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","items":{"type":"string"},"type":["array","null"]},"runtimeSourcemapPausePatterns":{"default":[],"items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","type":"array"},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]}},"type":"object"}]},"showAsyncStacks":{"default":true,"description":"Show the async calls that led to the current call stack.","oneOf":[{"type":"boolean"},{"properties":{"onAttach":{"default":32,"type":"number"}},"required":["onAttach"],"type":"object"},{"properties":{"onceBreakpointResolved":{"default":32,"type":"number"}},"required":["onceBreakpointResolved"],"type":"object"}]},"skipFiles":{"default":["${/**"],"description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","type":"array"},"smartStep":{"default":true,"description":"Automatically step through generated code that cannot be mapped back to the original source.","type":"boolean"},"sourceMapPathOverrides":{"default":{"meteor://💻app/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","webpack://?:*/*":"${workspaceFolder}/*"},"description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","type":"object"},"sourceMapRenames":{"default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.","type":"boolean"},"sourceMaps":{"default":true,"description":"Use JavaScript source maps (if they exist).","type":"boolean"},"timeout":{"default":10000,"description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","type":"number"},"timeouts":{"additionalProperties":false,"default":{},"description":"Timeouts for several debugger operations.","markdownDescription":"Timeouts for several debugger operations.","properties":{"hoverEvaluation":{"default":500,"description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","type":"number"},"sourceMapCumulativePause":{"default":1000,"description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","type":"number"},"sourceMapMinPause":{"default":1000,"description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","type":"number"}},"type":"object"},"trace":{"default":true,"description":"Configures what diagnostic output is produced.","oneOf":[{"description":"Trace may be set to 'true' to write diagnostic logs to the disk.","type":"boolean"},{"additionalProperties":false,"properties":{"logFile":{"description":"Configures where on disk logs are written.","type":["string","null"]},"stdio":{"description":"Whether to return trace data from the launched application or browser.","type":"boolean"}},"type":"object"}]},"url":{"default":"http://localhost:8080","description":"Will search for a tab with this exact url and attach to it, if found","tags":["setup"],"type":"string"},"urlFilter":{"default":"","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","type":"string"},"useWebView":{"default":false,"description":"When 'true', the debugger will treat the runtime executable as a host application that contains a WebView allowing you to debug the WebView script content.","type":"boolean"},"userDataDir":{"default":true,"description":"By default, the browser is launched with a separate user profile in a temp folder. Use this option to override it. Set to false to launch with your default user profile. A new browser can't be launched if an instance is already running from `userDataDir`.","type":["string","boolean"]},"vueComponentPaths":{"default":["${workspaceFolder}/**/*.vue"],"description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","type":"array"},"webRoot":{"default":"${workspaceFolder}","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","tags":["setup"],"type":"string"}}}},"configurationSnippets":[{"body":{"name":"Launch Edge","request":"launch","type":"msedge","url":"http://localhost:8080","webRoot":"^\"${2:\\${workspaceFolder\\}}\""},"description":"Launch Edge to debug a URL","label":"Edge: Launch"},{"body":{"name":"Attach to Edge","port":9222,"request":"attach","type":"msedge","webRoot":"^\"${2:\\${workspaceFolder\\}}\""},"description":"Attach to an instance of Edge already in debug mode","label":"Edge: Attach"}],"label":"Web App (Edge)","strings":{"unverifiedBreakpoints":"Some of your breakpoints could not be set. If you're having an issue, you can [troubleshoot your launch configuration](command:extension.js-debug.createDiagnostics)."},"type":"msedge"}],"commands":[{"command":"extension.js-debug.prettyPrint","title":"Pretty print for debugging","category":"Debug","icon":"$(json)"},{"command":"extension.js-debug.toggleSkippingFile","title":"Toggle Skipping this File","category":"Debug"},{"command":"extension.js-debug.addCustomBreakpoints","title":"Toggle Event Listener Breakpoints","icon":"$(add)"},{"command":"extension.js-debug.removeAllCustomBreakpoints","title":"Remove All Event Listener Breakpoints","icon":"$(close-all)"},{"command":"extension.js-debug.addXHRBreakpoints","title":"Add XHR/fetch Breakpoint","icon":"$(add)"},{"command":"extension.js-debug.removeXHRBreakpoint","title":"Remove XHR/fetch Breakpoint","icon":"$(remove)"},{"command":"extension.js-debug.editXHRBreakpoints","title":"Edit XHR/fetch Breakpoint","icon":"$(edit)"},{"command":"extension.pwa-node-debug.attachNodeProcess","title":"Attach to Node Process","category":"Debug"},{"command":"extension.js-debug.npmScript","title":"Debug npm Script","category":"Debug"},{"command":"extension.js-debug.createDebuggerTerminal","title":"JavaScript Debug Terminal","category":"Debug"},{"command":"extension.js-debug.startProfile","title":"Take Performance Profile","category":"Debug","icon":"$(record)"},{"command":"extension.js-debug.stopProfile","title":"Stop Performance Profile","category":"Debug","icon":"resources/dark/stop-profiling.svg"},{"command":"extension.js-debug.revealPage","title":"Focus Tab","category":"Debug"},{"command":"extension.js-debug.debugLink","title":"Open Link","category":"Debug"},{"command":"extension.js-debug.createDiagnostics","title":"Diagnose Breakpoint Problems","category":"Debug"},{"command":"extension.js-debug.getDiagnosticLogs","title":"Save Diagnostic JS Debug Logs","category":"Debug"},{"command":"extension.node-debug.startWithStopOnEntry","title":"Start Debugging and Stop on Entry","category":"Debug"},{"command":"extension.js-debug.openEdgeDevTools","title":"Open Browser Devtools","icon":"$(inspect)","category":"Debug"},{"command":"extension.js-debug.callers.add","title":"Exclude Caller","category":"Debug"},{"command":"extension.js-debug.callers.remove","title":"Remove excluded caller","icon":"$(close)"},{"command":"extension.js-debug.callers.removeAll","title":"Remove all excluded callers","icon":"$(clear-all)"},{"command":"extension.js-debug.callers.goToCaller","title":"Go to caller location","icon":"$(call-outgoing)"},{"command":"extension.js-debug.callers.gotToTarget","title":"Go to target location","icon":"$(call-incoming)"},{"command":"extension.js-debug.enableSourceMapStepping","title":"Enable Source Mapped Stepping","icon":"$(compass-dot)"},{"command":"extension.js-debug.disableSourceMapStepping","title":"Disable Source Mapped Stepping","icon":"$(compass)"},{"command":"extension.js-debug.network.viewRequest","title":"View Request as cURL","icon":"$(arrow-right)"},{"command":"extension.js-debug.network.clear","title":"Clear Network Log","icon":"$(clear-all)"},{"command":"extension.js-debug.network.openBody","title":"Open Response Body"},{"command":"extension.js-debug.network.openBodyInHex","title":"Open Response Body in Hex Editor"},{"command":"extension.js-debug.network.replayXHR","title":"Replay Request"},{"command":"extension.js-debug.network.copyUri","title":"Copy Request URL"}],"keybindings":[{"command":"extension.node-debug.startWithStopOnEntry","key":"F10","mac":"F10","when":"debugConfigurationType == pwa-node && !inDebugMode || debugConfigurationType == pwa-extensionHost && !inDebugMode || debugConfigurationType == node && !inDebugMode"},{"command":"extension.node-debug.startWithStopOnEntry","key":"F11","mac":"F11","when":"debugConfigurationType == pwa-node && !inDebugMode && activeViewlet == workbench.view.debug || debugConfigurationType == pwa-extensionHost && !inDebugMode && activeViewlet == workbench.view.debug || debugConfigurationType == node && !inDebugMode && activeViewlet == workbench.view.debug"}],"configuration":{"title":"JavaScript Debugger","properties":{"debug.javascript.codelens.npmScripts":{"enum":["top","all","never"],"default":"top","description":"Where a \"Run\" and \"Debug\" code lens should be shown in your npm scripts. It may be on \"all\", scripts, on \"top\" of the script section, or \"never\"."},"debug.javascript.terminalOptions":{"type":"object","description":"Default launch options for the JavaScript debug terminal and npm scripts.","default":{},"properties":{"resolveSourceMapLocations":{"type":["array","null"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","default":["${workspaceFolder}/**","!**/node_modules/**"],"items":{"type":"string"}},"outFiles":{"type":["array"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"items":{"type":"string"},"tags":["setup"]},"pauseForSourceMap":{"type":"boolean","markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","default":false},"showAsyncStacks":{"description":"Show the async calls that led to the current call stack.","default":true,"oneOf":[{"type":"boolean"},{"type":"object","required":["onAttach"],"properties":{"onAttach":{"type":"number","default":32}}},{"type":"object","required":["onceBreakpointResolved"],"properties":{"onceBreakpointResolved":{"type":"number","default":32}}}]},"skipFiles":{"type":"array","description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","default":["${/**"]},"smartStep":{"type":"boolean","description":"Automatically step through generated code that cannot be mapped back to the original source.","default":true},"sourceMaps":{"type":"boolean","description":"Use JavaScript source maps (if they exist).","default":true},"sourceMapRenames":{"type":"boolean","default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers."},"sourceMapPathOverrides":{"type":"object","description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","default":{"webpack://?:*/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","meteor://💻app/*":"${workspaceFolder}/*"}},"timeout":{"type":"number","description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","default":10000},"timeouts":{"type":"object","description":"Timeouts for several debugger operations.","default":{},"properties":{"sourceMapMinPause":{"type":"number","description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","default":1000},"sourceMapCumulativePause":{"type":"number","description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","default":1000},"hoverEvaluation":{"type":"number","description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","default":500}},"additionalProperties":false,"markdownDescription":"Timeouts for several debugger operations."},"trace":{"description":"Configures what diagnostic output is produced.","default":true,"oneOf":[{"type":"boolean","description":"Trace may be set to 'true' to write diagnostic logs to the disk."},{"type":"object","additionalProperties":false,"properties":{"stdio":{"type":"boolean","description":"Whether to return trace data from the launched application or browser."},"logFile":{"type":["string","null"],"description":"Configures where on disk logs are written."}}}]},"outputCapture":{"enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`.","default":"console"},"enableContentValidation":{"default":true,"type":"boolean","description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example."},"customDescriptionGenerator":{"type":"string","description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n "},"customPropertiesGenerator":{"type":"string","deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181"},"cascadeTerminateToConfigurations":{"type":"array","items":{"type":"string","uniqueItems":true},"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped."},"enableDWARF":{"type":"boolean","default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function."},"cwd":{"type":"string","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","default":"${workspaceFolder}","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"]},"localRoot":{"type":["string","null"],"description":"Path to the local directory containing the program.","default":null},"remoteRoot":{"type":["string","null"],"description":"Absolute path to the remote directory containing the program.","default":null},"autoAttachChildProcesses":{"type":"boolean","description":"Attach debugger to new child processes automatically.","default":true},"env":{"type":"object","additionalProperties":{"type":["string","null"]},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","default":{},"tags":["setup"]},"envFile":{"type":"string","description":"Absolute path to a file containing environment variable definitions.","default":"${workspaceFolder}/.env"},"runtimeSourcemapPausePatterns":{"type":"array","items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","default":[]},"nodeVersionHint":{"type":"number","minimum":8,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","default":12},"command":{"type":["string","null"],"description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","default":"npm start","tags":["setup"]}}},"debug.javascript.automaticallyTunnelRemoteServer":{"type":"boolean","description":"When debugging a remote web app, configures whether to automatically tunnel the remote server to your local machine.","default":true},"debug.javascript.debugByLinkOptions":{"default":"on","description":"Options used when debugging open links clicked from inside the JavaScript Debug Terminal. Can be set to \"off\" to disable this behavior, or \"always\" to enable debugging in all terminals.","oneOf":[{"type":"string","enum":["on","off","always"]},{"type":"object","properties":{"resolveSourceMapLocations":{"type":["array","null"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","default":null,"items":{"type":"string"}},"outFiles":{"type":["array"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"items":{"type":"string"},"tags":["setup"]},"pauseForSourceMap":{"type":"boolean","markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","default":false},"showAsyncStacks":{"description":"Show the async calls that led to the current call stack.","default":true,"oneOf":[{"type":"boolean"},{"type":"object","required":["onAttach"],"properties":{"onAttach":{"type":"number","default":32}}},{"type":"object","required":["onceBreakpointResolved"],"properties":{"onceBreakpointResolved":{"type":"number","default":32}}}]},"skipFiles":{"type":"array","description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","default":["${/**"]},"smartStep":{"type":"boolean","description":"Automatically step through generated code that cannot be mapped back to the original source.","default":true},"sourceMaps":{"type":"boolean","description":"Use JavaScript source maps (if they exist).","default":true},"sourceMapRenames":{"type":"boolean","default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers."},"sourceMapPathOverrides":{"type":"object","description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","default":{"webpack://?:*/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","meteor://💻app/*":"${workspaceFolder}/*"}},"timeout":{"type":"number","description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","default":10000},"timeouts":{"type":"object","description":"Timeouts for several debugger operations.","default":{},"properties":{"sourceMapMinPause":{"type":"number","description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","default":1000},"sourceMapCumulativePause":{"type":"number","description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","default":1000},"hoverEvaluation":{"type":"number","description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","default":500}},"additionalProperties":false,"markdownDescription":"Timeouts for several debugger operations."},"trace":{"description":"Configures what diagnostic output is produced.","default":true,"oneOf":[{"type":"boolean","description":"Trace may be set to 'true' to write diagnostic logs to the disk."},{"type":"object","additionalProperties":false,"properties":{"stdio":{"type":"boolean","description":"Whether to return trace data from the launched application or browser."},"logFile":{"type":["string","null"],"description":"Configures where on disk logs are written."}}}]},"outputCapture":{"enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`.","default":"console"},"enableContentValidation":{"default":true,"type":"boolean","description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example."},"customDescriptionGenerator":{"type":"string","description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n "},"customPropertiesGenerator":{"type":"string","deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181"},"cascadeTerminateToConfigurations":{"type":"array","items":{"type":"string","uniqueItems":true},"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped."},"enableDWARF":{"type":"boolean","default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function."},"disableNetworkCache":{"type":"boolean","description":"Controls whether to skip the network cache for each request","default":true},"pathMapping":{"type":"object","description":"A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk","default":{}},"webRoot":{"type":"string","description":"This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"","default":"${workspaceFolder}","tags":["setup"]},"urlFilter":{"type":"string","description":"Will search for a page with this url and attach to it, if found. Can have * wildcards.","default":""},"url":{"type":"string","description":"Will search for a tab with this exact url and attach to it, if found","default":"http://localhost:8080","tags":["setup"]},"inspectUri":{"type":["string","null"],"description":"Format to use to rewrite the inspectUri: It's a template string that interpolates keys in `{curlyBraces}`. Available keys are:\n - `url.*` is the parsed address of the running application. For instance, `{url.port}`, `{url.hostname}`\n - `port` is the debug port that Chrome is listening on.\n - `browserInspectUri` is the inspector URI on the launched browser\n - `browserInspectUriPath` is the path part of the inspector URI on the launched browser (e.g.: \"/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2\").\n - `wsProtocol` is the hinted websocket protocol. This is set to `wss` if the original URL is `https`, or `ws` otherwise.\n","default":null},"vueComponentPaths":{"type":"array","description":"A list of file glob patterns to find `*.vue` components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.","default":["${workspaceFolder}/**/*.vue"]},"server":{"oneOf":[{"type":"object","description":"Configures a web server to start up. Takes the same configuration as the 'node' launch task.","additionalProperties":false,"default":{"program":"node my-server.js"},"properties":{"resolveSourceMapLocations":{"type":["array","null"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","default":["${workspaceFolder}/**","!**/node_modules/**"],"items":{"type":"string"}},"outFiles":{"type":["array"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"items":{"type":"string"},"tags":["setup"]},"pauseForSourceMap":{"type":"boolean","markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","default":false},"showAsyncStacks":{"description":"Show the async calls that led to the current call stack.","default":true,"oneOf":[{"type":"boolean"},{"type":"object","required":["onAttach"],"properties":{"onAttach":{"type":"number","default":32}}},{"type":"object","required":["onceBreakpointResolved"],"properties":{"onceBreakpointResolved":{"type":"number","default":32}}}]},"skipFiles":{"type":"array","description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","default":["${/**"]},"smartStep":{"type":"boolean","description":"Automatically step through generated code that cannot be mapped back to the original source.","default":true},"sourceMaps":{"type":"boolean","description":"Use JavaScript source maps (if they exist).","default":true},"sourceMapRenames":{"type":"boolean","default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers."},"sourceMapPathOverrides":{"type":"object","description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","default":{"webpack://?:*/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","meteor://💻app/*":"${workspaceFolder}/*"}},"timeout":{"type":"number","description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","default":10000},"timeouts":{"type":"object","description":"Timeouts for several debugger operations.","default":{},"properties":{"sourceMapMinPause":{"type":"number","description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","default":1000},"sourceMapCumulativePause":{"type":"number","description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","default":1000},"hoverEvaluation":{"type":"number","description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","default":500}},"additionalProperties":false,"markdownDescription":"Timeouts for several debugger operations."},"trace":{"description":"Configures what diagnostic output is produced.","default":true,"oneOf":[{"type":"boolean","description":"Trace may be set to 'true' to write diagnostic logs to the disk."},{"type":"object","additionalProperties":false,"properties":{"stdio":{"type":"boolean","description":"Whether to return trace data from the launched application or browser."},"logFile":{"type":["string","null"],"description":"Configures where on disk logs are written."}}}]},"outputCapture":{"enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`.","default":"console"},"enableContentValidation":{"default":true,"type":"boolean","description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example."},"customDescriptionGenerator":{"type":"string","description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n "},"customPropertiesGenerator":{"type":"string","deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181"},"cascadeTerminateToConfigurations":{"type":"array","items":{"type":"string","uniqueItems":true},"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped."},"enableDWARF":{"type":"boolean","default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function."},"cwd":{"type":"string","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","default":"${workspaceFolder}","tags":["setup"]},"localRoot":{"type":["string","null"],"description":"Path to the local directory containing the program.","default":null},"remoteRoot":{"type":["string","null"],"description":"Absolute path to the remote directory containing the program.","default":null},"autoAttachChildProcesses":{"type":"boolean","description":"Attach debugger to new child processes automatically.","default":true},"env":{"type":"object","additionalProperties":{"type":["string","null"]},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","default":{},"tags":["setup"]},"envFile":{"type":"string","description":"Absolute path to a file containing environment variable definitions.","default":"${workspaceFolder}/.env"},"runtimeSourcemapPausePatterns":{"type":"array","items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","default":[]},"nodeVersionHint":{"type":"number","minimum":8,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","default":12},"program":{"type":"string","description":"Absolute path to the program. Generated value is guessed by looking at package.json and opened files. Edit this attribute.","default":"","tags":["setup"]},"stopOnEntry":{"type":["boolean","string"],"description":"Automatically stop program after launch.","default":true},"console":{"type":"string","enum":["internalConsole","integratedTerminal","externalTerminal"],"enumDescriptions":["VS Code Debug Console (which doesn't support to read input from a program)","VS Code's integrated terminal","External terminal that can be configured via user settings"],"description":"Where to launch the debug target.","default":"internalConsole"},"args":{"type":["array","string"],"description":"Command line arguments passed to the program.\n\nCan be an array of strings or a single string. When the program is launched in a terminal, setting this property to a single string will result in the arguments not being escaped for the shell.","items":{"type":"string"},"default":[],"tags":["setup"]},"restart":{"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","default":true,"oneOf":[{"type":"boolean"},{"type":"object","properties":{"delay":{"type":"number","minimum":0,"default":1000},"maxAttempts":{"type":"number","minimum":0,"default":10}}}]},"runtimeExecutable":{"type":["string","null"],"markdownDescription":"Runtime to use. Either an absolute path or the name of a runtime available on the PATH. If omitted `node` is assumed.","default":"node"},"runtimeVersion":{"type":"string","markdownDescription":"Version of `node` runtime to use. Requires `nvm`.","default":"default"},"runtimeArgs":{"type":"array","description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"default":[],"tags":["setup"]},"profileStartup":{"type":"boolean","description":"If true, will start profiling as soon as the process launches","default":true},"attachSimplePort":{"oneOf":[{"type":"integer"},{"type":"string","pattern":"^\\${.*}$"}],"description":"If set, attaches to the process via the given port. This is generally no longer necessary for Node.js programs and loses the ability to debug child processes, but can be useful in more esoteric scenarios such as with Deno and Docker launches. If set to 0, a random port will be chosen and --inspect-brk added to the launch arguments automatically.","default":9229},"killBehavior":{"type":"string","enum":["forceful","polite","none"],"default":"forceful","markdownDescription":"Configures how debug processes are killed when stopping the session. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen."},"experimentalNetworking":{"type":"string","default":"auto","description":"Enable experimental inspection in Node.js. When set to `auto` this is enabled for versions of Node.js that support it. It can be set to `on` or `off` to enable or disable it explicitly.","enum":["auto","on","off"]}}},{"type":"object","description":"JavaScript Debug Terminal","additionalProperties":false,"default":{"program":"npm start"},"properties":{"resolveSourceMapLocations":{"type":["array","null"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","default":["${workspaceFolder}/**","!**/node_modules/**"],"items":{"type":"string"}},"outFiles":{"type":["array"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"items":{"type":"string"},"tags":["setup"]},"pauseForSourceMap":{"type":"boolean","markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","default":false},"showAsyncStacks":{"description":"Show the async calls that led to the current call stack.","default":true,"oneOf":[{"type":"boolean"},{"type":"object","required":["onAttach"],"properties":{"onAttach":{"type":"number","default":32}}},{"type":"object","required":["onceBreakpointResolved"],"properties":{"onceBreakpointResolved":{"type":"number","default":32}}}]},"skipFiles":{"type":"array","description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","default":["${/**"]},"smartStep":{"type":"boolean","description":"Automatically step through generated code that cannot be mapped back to the original source.","default":true},"sourceMaps":{"type":"boolean","description":"Use JavaScript source maps (if they exist).","default":true},"sourceMapRenames":{"type":"boolean","default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers."},"sourceMapPathOverrides":{"type":"object","description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","default":{"webpack://?:*/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","meteor://💻app/*":"${workspaceFolder}/*"}},"timeout":{"type":"number","description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","default":10000},"timeouts":{"type":"object","description":"Timeouts for several debugger operations.","default":{},"properties":{"sourceMapMinPause":{"type":"number","description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","default":1000},"sourceMapCumulativePause":{"type":"number","description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","default":1000},"hoverEvaluation":{"type":"number","description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","default":500}},"additionalProperties":false,"markdownDescription":"Timeouts for several debugger operations."},"trace":{"description":"Configures what diagnostic output is produced.","default":true,"oneOf":[{"type":"boolean","description":"Trace may be set to 'true' to write diagnostic logs to the disk."},{"type":"object","additionalProperties":false,"properties":{"stdio":{"type":"boolean","description":"Whether to return trace data from the launched application or browser."},"logFile":{"type":["string","null"],"description":"Configures where on disk logs are written."}}}]},"outputCapture":{"enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`.","default":"console"},"enableContentValidation":{"default":true,"type":"boolean","description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example."},"customDescriptionGenerator":{"type":"string","description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n "},"customPropertiesGenerator":{"type":"string","deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181"},"cascadeTerminateToConfigurations":{"type":"array","items":{"type":"string","uniqueItems":true},"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped."},"enableDWARF":{"type":"boolean","default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function."},"cwd":{"type":"string","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","default":"${workspaceFolder}","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"]},"localRoot":{"type":["string","null"],"description":"Path to the local directory containing the program.","default":null},"remoteRoot":{"type":["string","null"],"description":"Absolute path to the remote directory containing the program.","default":null},"autoAttachChildProcesses":{"type":"boolean","description":"Attach debugger to new child processes automatically.","default":true},"env":{"type":"object","additionalProperties":{"type":["string","null"]},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","default":{},"tags":["setup"]},"envFile":{"type":"string","description":"Absolute path to a file containing environment variable definitions.","default":"${workspaceFolder}/.env"},"runtimeSourcemapPausePatterns":{"type":"array","items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","default":[]},"nodeVersionHint":{"type":"number","minimum":8,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","default":12},"command":{"type":["string","null"],"description":"Command to run in the launched terminal. If not provided, the terminal will open without launching a program.","default":"npm start","tags":["setup"]}}}]},"perScriptSourcemaps":{"type":"string","default":"auto","enum":["yes","no","auto"],"description":"Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to \"auto\", we'll detect known cases where this is appropriate."},"port":{"type":"number","description":"Port for the browser to listen on. Defaults to \"0\", which will cause the browser to be debugged via pipes, which is generally more secure and should be chosen unless you need to attach to the browser from another tool.","default":0},"file":{"type":"string","description":"A local html file to open in the browser","default":"${workspaceFolder}/index.html","tags":["setup"]},"userDataDir":{"type":["string","boolean"],"description":"By default, the browser is launched with a separate user profile in a temp folder. Use this option to override it. Set to false to launch with your default user profile. A new browser can't be launched if an instance is already running from `userDataDir`.","default":true},"includeDefaultArgs":{"type":"boolean","description":"Whether default browser launch arguments (to disable features that may make debugging harder) will be included in the launch.","default":true},"includeLaunchArgs":{"type":"boolean","description":"Advanced: whether any default launch/debugging arguments are set on the browser. The debugger will assume the browser will use pipe debugging such as that which is provided with `--remote-debugging-pipe`.","default":true},"runtimeExecutable":{"type":["string","null"],"description":"Either 'canary', 'stable', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or CHROME_PATH environment variable.","default":"stable"},"runtimeArgs":{"type":"array","description":"Optional arguments passed to the runtime executable.","items":{"type":"string"},"default":[]},"env":{"type":"object","description":"Optional dictionary of environment key/value pairs for the browser.","default":{}},"cwd":{"type":"string","description":"Optional working directory for the runtime executable.","default":null},"profileStartup":{"type":"boolean","description":"If true, will start profiling soon as the process launches","default":true},"cleanUp":{"type":"string","enum":["wholeBrowser","onlyTab"],"description":"What clean-up to do after the debugging session finishes. Close only the tab being debug, vs. close the whole browser.","default":"wholeBrowser"},"killBehavior":{"type":"string","enum":["forceful","polite","none"],"default":"forceful","markdownDescription":"Configures how browser processes are killed when stopping the session with `cleanUp: wholeBrowser`. Can be:\n\n- forceful (default): forcefully tears down the process tree. Sends SIGKILL on posix, or `taskkill.exe /F` on Windows.\n- polite: gracefully tears down the process tree. It's possible that misbehaving processes continue to run after shutdown in this way. Sends SIGTERM on posix, or `taskkill.exe` with no `/F` (force) flag on Windows.\n- none: no termination will happen."},"browserLaunchLocation":{"description":"Forces the browser to be launched in one location. In a remote workspace (through ssh or WSL, for example) this can be used to open the browser on the remote machine rather than locally.","default":null,"oneOf":[{"type":"null"},{"type":"string","enum":["ui","workspace"]}]},"enabled":{"type":"string","enum":["on","off","always"]}}}]},"debug.javascript.pickAndAttachOptions":{"type":"object","default":{},"markdownDescription":"Default options used when debugging a process through the `Debug: Attach to Node.js Process` command","properties":{"resolveSourceMapLocations":{"type":["array","null"],"description":"A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with \"!\" to exclude them. May be set to an empty array or null to avoid restriction.","default":["${workspaceFolder}/**","!**/node_modules/**"],"items":{"type":"string"}},"outFiles":{"type":["array"],"description":"If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with `!` the files are excluded. If not specified, the generated code is expected in the same directory as its source.","default":["${workspaceFolder}/**/*.(m|c|)js","!**/node_modules/**"],"items":{"type":"string"},"tags":["setup"]},"pauseForSourceMap":{"type":"boolean","markdownDescription":"Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as `rootPath` is not disabled.","default":false},"showAsyncStacks":{"description":"Show the async calls that led to the current call stack.","default":true,"oneOf":[{"type":"boolean"},{"type":"object","required":["onAttach"],"properties":{"onAttach":{"type":"number","default":32}}},{"type":"object","required":["onceBreakpointResolved"],"properties":{"onceBreakpointResolved":{"type":"number","default":32}}}]},"skipFiles":{"type":"array","description":"An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, `[\"**/node_modules/**\", \"!**/node_modules/my-module/**\"]`","default":["${/**"]},"smartStep":{"type":"boolean","description":"Automatically step through generated code that cannot be mapped back to the original source.","default":true},"sourceMaps":{"type":"boolean","description":"Use JavaScript source maps (if they exist).","default":true},"sourceMapRenames":{"type":"boolean","default":true,"description":"Whether to use the \"names\" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers."},"sourceMapPathOverrides":{"type":"object","description":"A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.","default":{"webpack://?:*/*":"${workspaceFolder}/*","webpack:///./~/*":"${workspaceFolder}/node_modules/*","meteor://💻app/*":"${workspaceFolder}/*"}},"timeout":{"type":"number","description":"Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.","default":10000},"timeouts":{"type":"object","description":"Timeouts for several debugger operations.","default":{},"properties":{"sourceMapMinPause":{"type":"number","description":"Minimum time in milliseconds spent waiting for each source-map to be processed when a script is being parsed","default":1000},"sourceMapCumulativePause":{"type":"number","description":"Extra time in milliseconds allowed per session to be spent waiting for source-maps to be processed, after the minimum time (sourceMapMinPause) has been exhausted","default":1000},"hoverEvaluation":{"type":"number","description":"Time until value evaluation for hovered symbols is aborted. If set to 0, hover evaluation does never time out.","default":500}},"additionalProperties":false,"markdownDescription":"Timeouts for several debugger operations."},"trace":{"description":"Configures what diagnostic output is produced.","default":true,"oneOf":[{"type":"boolean","description":"Trace may be set to 'true' to write diagnostic logs to the disk."},{"type":"object","additionalProperties":false,"properties":{"stdio":{"type":"boolean","description":"Whether to return trace data from the launched application or browser."},"logFile":{"type":["string","null"],"description":"Configures where on disk logs are written."}}}]},"outputCapture":{"enum":["console","std"],"markdownDescription":"From where to capture output messages: the default debug API if set to `console`, or stdout/stderr streams if set to `std`.","default":"console"},"enableContentValidation":{"default":true,"type":"boolean","description":"Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example."},"customDescriptionGenerator":{"type":"string","description":"Customize the textual description the debugger shows for objects (local variables, etc...). Samples:\n 1. this.toString() // will call toString to print all objects\n 2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue\n 3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue\n "},"customPropertiesGenerator":{"type":"string","deprecated":true,"description":"Customize the properties shown for an object in the debugger (local variables, etc...). Samples:\n 1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects\n 2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)\n 3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties\n\n Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181"},"cascadeTerminateToConfigurations":{"type":"array","items":{"type":"string","uniqueItems":true},"default":[],"description":"A list of debug sessions which, when this debug session is terminated, will also be stopped."},"enableDWARF":{"type":"boolean","default":true,"markdownDescription":"Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the `ms-vscode.wasm-dwarf-debugging` extension to function."},"cwd":{"type":"string","description":"Absolute path to the working directory of the program being debugged. If you've set localRoot then cwd will match that value otherwise it falls back to your workspaceFolder","default":"${workspaceFolder}","docDefault":"localRoot || ${workspaceFolder}","tags":["setup"]},"localRoot":{"type":["string","null"],"description":"Path to the local directory containing the program.","default":null},"remoteRoot":{"type":["string","null"],"description":"Absolute path to the remote directory containing the program.","default":null},"autoAttachChildProcesses":{"type":"boolean","description":"Attach debugger to new child processes automatically.","default":true},"env":{"type":"object","additionalProperties":{"type":["string","null"]},"markdownDescription":"Environment variables passed to the program. The value `null` removes the variable from the environment.","default":{},"tags":["setup"]},"envFile":{"type":"string","description":"Absolute path to a file containing environment variable definitions.","default":"${workspaceFolder}/.env"},"runtimeSourcemapPausePatterns":{"type":"array","items":{"type":"string"},"markdownDescription":"A list of patterns at which to manually insert entrypoint breakpoints. This can be useful to give the debugger an opportunity to set breakpoints when using sourcemaps that don't exist or can't be detected before launch, such as [with the Serverless framework](https://github.com/microsoft/vscode-js-debug/issues/492).","default":[]},"nodeVersionHint":{"type":"number","minimum":8,"description":"Allows you to explicitly specify the Node version that's running, which can be used to disable or enable certain behaviors in cases where the automatic version detection does not work.","default":12},"address":{"type":"string","description":"TCP/IP address of process to be debugged. Default is 'localhost'.","default":"localhost"},"port":{"description":"Debug port to attach to. Default is 9229.","default":9229,"oneOf":[{"type":"integer"},{"type":"string","pattern":"^\\${.*}$"}],"tags":["setup"]},"websocketAddress":{"type":"string","description":"Exact websocket address to attach to. If unspecified, it will be discovered from the address and port."},"remoteHostHeader":{"type":"string","description":"Explicit Host header to use when connecting to the websocket of inspector. If unspecified, the host header will be set to 'localhost'. This is useful when the inspector is running behind a proxy that only accept particular Host header."},"restart":{"description":"Try to reconnect to the program if we lose connection. If set to `true`, we'll try once a second, forever. You can customize the interval and maximum number of attempts by specifying the `delay` and `maxAttempts` in an object instead.","default":true,"oneOf":[{"type":"boolean"},{"type":"object","properties":{"delay":{"type":"number","minimum":0,"default":1000},"maxAttempts":{"type":"number","minimum":0,"default":10}}}]},"processId":{"type":"string","description":"ID of process to attach to.","default":"${command:PickProcess}"},"attachExistingChildren":{"type":"boolean","description":"Whether to attempt to attach to already-spawned child processes.","default":false},"continueOnAttach":{"type":"boolean","markdownDescription":"If true, we'll automatically resume programs launched and waiting on `--inspect-brk`","default":true}}},"debug.javascript.autoAttachFilter":{"type":"string","default":"disabled","enum":["always","smart","onlyWithFlag","disabled"],"enumDescriptions":["Auto attach to every Node.js process launched in the terminal.","Auto attach when running scripts that aren't in a node_modules folder.","Only auto attach when the `--inspect` is given.","Auto attach is disabled and not shown in status bar."],"markdownDescription":"Configures which processes to automatically attach and debug when `#debug.node.autoAttach#` is on. A Node process launched with the `--inspect` flag will always be attached to, regardless of this setting."},"debug.javascript.autoAttachSmartPattern":{"type":"array","items":{"type":"string"},"default":["${workspaceFolder}/**","!**/node_modules/**","**/$KNOWN_TOOLS$/**"],"markdownDescription":"Configures glob patterns for determining when to attach in \"smart\" `#debug.javascript.autoAttachFilter#` mode. `$KNOWN_TOOLS$` is replaced with a list of names of common test and code runners. [Read more on the VS Code docs](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_auto-attach-smart-patterns)."},"debug.javascript.breakOnConditionalError":{"type":"boolean","default":false,"markdownDescription":"Whether to stop when conditional breakpoints throw an error."},"debug.javascript.unmapMissingSources":{"type":"boolean","default":false,"description":"Configures whether sourcemapped file where the original file can't be read will automatically be unmapped. If this is false (default), a prompt is shown."},"debug.javascript.defaultRuntimeExecutable":{"type":"object","default":{"pwa-node":"node"},"markdownDescription":"The default `runtimeExecutable` used for launch configurations, if unspecified. This can be used to config custom paths to Node.js or browser installations.","properties":{"pwa-node":{"type":"string"},"pwa-chrome":{"type":"string"},"pwa-msedge":{"type":"string"}}},"debug.javascript.resourceRequestOptions":{"type":"object","default":{},"markdownDescription":"Request options to use when loading resources, such as source maps, in the debugger. You may need to configure this if your sourcemaps require authentication or use a self-signed certificate, for instance. Options are used to create a request using the [`got`](https://github.com/sindresorhus/got) library.\n\nA common case to disable certificate verification can be done by passing `{ \"https\": { \"rejectUnauthorized\": false } }`."},"debug.javascript.enableNetworkView":{"type":"boolean","default":true,"description":"Enables the experimental network view for targets that support it."}}},"grammars":[{"language":"wat","scopeName":"text.wat","path":"./src/ui/basic-wat.tmLanguage.json"}],"languages":[{"id":"wat","extensions":[".wat",".wasm"],"aliases":["WebAssembly Text Format"],"firstLine":"^\\(module","mimetypes":["text/wat"],"configuration":"./src/ui/basic-wat.configuration.json"}],"terminal":{"profiles":[{"id":"extension.js-debug.debugTerminal","title":"JavaScript Debug Terminal","icon":"$(debug)"}]},"views":{"debug":[{"id":"jsBrowserBreakpoints","name":"Event Listener Breakpoints","when":"debugType == pwa-chrome || debugType == pwa-msedge"},{"id":"jsExcludedCallers","name":"Excluded Callers","when":"debugType == pwa-extensionHost && jsDebugHasExcludedCallers || debugType == node-terminal && jsDebugHasExcludedCallers || debugType == pwa-node && jsDebugHasExcludedCallers || debugType == pwa-chrome && jsDebugHasExcludedCallers || debugType == pwa-msedge && jsDebugHasExcludedCallers"},{"id":"jsDebugNetworkTree","name":"Network","when":"jsDebugNetworkAvailable"}]},"viewsWelcome":[{"view":"debug","contents":"[JavaScript Debug Terminal](command:extension.js-debug.createDebuggerTerminal)\n\nYou can use the JavaScript Debug Terminal to debug Node.js processes run on the command line.\n\n[Debug URL](command:extension.js-debug.debugLink)","when":"debugStartLanguage == javascript && !isWeb || debugStartLanguage == typescript && !isWeb || debugStartLanguage == javascriptreact && !isWeb || debugStartLanguage == typescriptreact && !isWeb"},{"view":"debug","contents":"[JavaScript Debug Terminal](command:extension.js-debug.createDebuggerTerminal)\n\nYou can use the JavaScript Debug Terminal to debug Node.js processes run on the command line.","when":"debugStartLanguage == javascript && isWeb || debugStartLanguage == typescript && isWeb || debugStartLanguage == javascriptreact && isWeb || debugStartLanguage == typescriptreact && isWeb"}]}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/ms-vscode.js-debug","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","metadata":{},"isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"ms-vscode.js-debug-companion"},"manifest":{"name":"js-debug-companion","displayName":"JavaScript Debugger Companion Extension","description":"Companion extension to js-debug that provides capability for remote debugging","version":"1.1.3","publisher":"ms-vscode","engines":{"vscode":"^1.90.0"},"icon":"resources/logo.png","categories":["Other"],"repository":{"type":"git","url":"https://github.com/microsoft/vscode-js-debug-companion.git"},"author":"Connor Peet ","license":"MIT","bugs":{"url":"https://github.com/microsoft/vscode-js-debug-companion/issues"},"homepage":"https://github.com/microsoft/vscode-js-debug-companion#readme","capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":true}},"activationEvents":["onCommand:js-debug-companion.launchAndAttach","onCommand:js-debug-companion.kill","onCommand:js-debug-companion.launch","onCommand:js-debug-companion.defaultBrowser"],"main":"./out/extension.js","contributes":{},"extensionKind":["ui"],"api":"none","prettier":{"trailingComma":"all","singleQuote":true,"printWidth":100,"tabWidth":2,"arrowParens":"avoid"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/ms-vscode.js-debug-companion","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","metadata":{},"isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"ms-vscode.vscode-js-profile-table"},"manifest":{"name":"vscode-js-profile-table","version":"1.0.10","displayName":"Table Visualizer for JavaScript Profiles","description":"Text visualizer for profiles taken from the JavaScript debugger","author":"Connor Peet ","homepage":"https://github.com/microsoft/vscode-js-profile-visualizer#readme","license":"MIT","main":"out/extension.js","browser":"out/extension.web.js","repository":{"type":"git","url":"https://github.com/microsoft/vscode-js-profile-visualizer.git"},"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"icon":"resources/icon.png","publisher":"ms-vscode","sideEffects":false,"engines":{"vscode":"^1.74.0"},"contributes":{"customEditors":[{"viewType":"jsProfileVisualizer.cpuprofile.table","displayName":"CPU Profile Table Visualizer","priority":"default","selector":[{"filenamePattern":"*.cpuprofile"}]},{"viewType":"jsProfileVisualizer.heapprofile.table","displayName":"Heap Profile Table Visualizer","priority":"default","selector":[{"filenamePattern":"*.heapprofile"}]},{"viewType":"jsProfileVisualizer.heapsnapshot.table","displayName":"Heap Snapshot Table Visualizer","priority":"default","selector":[{"filenamePattern":"*.heapsnapshot"}]}],"commands":[{"command":"extension.jsProfileVisualizer.table.clearCodeLenses","title":"Clear Profile Code Lenses"}],"menus":{"commandPalette":[{"command":"extension.jsProfileVisualizer.table.clearCodeLenses","when":"jsProfileVisualizer.hasCodeLenses == true"}]}},"bugs":{"url":"https://github.com/microsoft/vscode-js-profile-visualizer/issues"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/ms-vscode.vscode-js-profile-table","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","metadata":{},"isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.builtin-notebook-renderers"},"manifest":{"name":"builtin-notebook-renderers","displayName":"Builtin Notebook Output Renderers","description":"Provides basic output renderers for notebooks","publisher":"vscode","version":"1.0.0","license":"MIT","icon":"media/icon.png","engines":{"vscode":"^1.57.0"},"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"contributes":{"notebookRenderer":[{"id":"vscode.builtin-renderer","entrypoint":"./renderer-out/index.js","displayName":"VS Code Builtin Notebook Output Renderer","requiresMessaging":"never","mimeTypes":["image/gif","image/png","image/jpeg","image/git","image/svg+xml","text/html","application/javascript","application/vnd.code.notebook.error","application/vnd.code.notebook.stdout","application/x.notebook.stdout","application/x.notebook.stream","application/vnd.code.notebook.stderr","application/x.notebook.stderr","text/plain"]}]},"scripts":{"compile":"npx gulp compile-extension:notebook-renderers && npm run build-notebook","watch":"npx gulp compile-watch:notebook-renderers","build-notebook":"node ./esbuild.mjs"},"devDependencies":{"@types/jsdom":"^21.1.0","@types/node":"^22.18.10","@types/vscode-notebook-renderer":"^1.60.0","jsdom":"^21.1.1"},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/notebook-renderers","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.npm"},"manifest":{"name":"npm","publisher":"vscode","displayName":"NPM support for VS Code","description":"Extension to add task support for npm scripts.","version":"1.0.1","private":true,"license":"MIT","engines":{"vscode":"0.10.x"},"icon":"images/npm_icon.png","categories":["Other"],"enabledApiProposals":["terminalQuickFixProvider"],"main":"./dist/npmMain","browser":"./dist/browser/npmBrowserMain","activationEvents":["onTaskType:npm","onLanguage:json","workspaceContains:package.json"],"capabilities":{"virtualWorkspaces":{"supported":"limited","description":"Functionality that requires running the 'npm' command is not available in virtual workspaces."},"untrustedWorkspaces":{"supported":"limited","description":"This extension executes tasks, which require trust to run."}},"contributes":{"languages":[{"id":"ignore","extensions":[".npmignore"]},{"id":"properties","extensions":[".npmrc"]}],"views":{"explorer":[{"id":"npm","name":"NPM Scripts","when":"npm:showScriptExplorer","icon":"$(json)","visibility":"hidden","contextualTitle":"NPM Scripts"}]},"commands":[{"command":"npm.runScript","title":"Run","icon":"$(run)"},{"command":"npm.debugScript","title":"Debug","icon":"$(debug)"},{"command":"npm.openScript","title":"Open"},{"command":"npm.runInstall","title":"Run Install"},{"command":"npm.refresh","title":"Refresh","icon":"$(refresh)"},{"command":"npm.runSelectedScript","title":"Run Script"},{"command":"npm.runScriptFromFolder","title":"Run NPM Script in Folder..."},{"command":"npm.packageManager","title":"Get Configured Package Manager"}],"menus":{"commandPalette":[{"command":"npm.refresh","when":"false"},{"command":"npm.runScript","when":"false"},{"command":"npm.debugScript","when":"false"},{"command":"npm.openScript","when":"false"},{"command":"npm.runInstall","when":"false"},{"command":"npm.runSelectedScript","when":"false"},{"command":"npm.runScriptFromFolder","when":"false"},{"command":"npm.packageManager","when":"false"}],"editor/context":[{"command":"npm.runSelectedScript","when":"resourceFilename == 'package.json' && resourceScheme == file","group":"navigation@+1"}],"view/title":[{"command":"npm.refresh","when":"view == npm","group":"navigation"}],"view/item/context":[{"command":"npm.openScript","when":"view == npm && viewItem == packageJSON","group":"navigation@1"},{"command":"npm.runInstall","when":"view == npm && viewItem == packageJSON","group":"navigation@2"},{"command":"npm.openScript","when":"view == npm && viewItem == script","group":"navigation@1"},{"command":"npm.runScript","when":"view == npm && viewItem == script","group":"navigation@2"},{"command":"npm.runScript","when":"view == npm && viewItem == script","group":"inline"},{"command":"npm.debugScript","when":"view == npm && viewItem == script","group":"inline"},{"command":"npm.debugScript","when":"view == npm && viewItem == script","group":"navigation@3"}],"explorer/context":[{"when":"config.npm.enableRunFromFolder && explorerViewletVisible && explorerResourceIsFolder && resourceScheme == file","command":"npm.runScriptFromFolder","group":"2_workspace"}]},"configuration":{"id":"npm","type":"object","title":"Npm","properties":{"npm.autoDetect":{"type":"string","enum":["off","on"],"default":"on","scope":"resource","description":"Controls whether npm scripts should be automatically detected."},"npm.runSilent":{"type":"boolean","default":false,"scope":"resource","markdownDescription":"Run npm commands with the `--silent` option."},"npm.packageManager":{"scope":"resource","type":"string","enum":["auto","npm","yarn","pnpm","bun"],"enumDescriptions":["Auto-detect which package manager to use based on lock files and installed package managers.","Use npm as the package manager.","Use yarn as the package manager.","Use pnpm as the package manager.","Use bun as the package manager."],"default":"auto","description":"The package manager used to install dependencies."},"npm.scriptRunner":{"scope":"resource","type":"string","enum":["auto","npm","yarn","pnpm","bun","node"],"enumDescriptions":["Auto-detect which script runner to use based on lock files and installed package managers.","Use npm as the script runner.","Use yarn as the script runner.","Use pnpm as the script runner.","Use bun as the script runner.","Use Node.js as the script runner."],"default":"auto","description":"The script runner used to run scripts."},"npm.exclude":{"type":["string","array"],"items":{"type":"string"},"description":"Configure glob patterns for folders that should be excluded from automatic script detection.","scope":"resource"},"npm.enableScriptExplorer":{"type":"boolean","default":false,"scope":"resource","deprecationMessage":"The NPM Script Explorer is now available in 'Views' menu in the Explorer in all folders.","description":"Enable an explorer view for npm scripts when there is no top-level 'package.json' file."},"npm.enableRunFromFolder":{"type":"boolean","default":false,"scope":"resource","description":"Enable running npm scripts contained in a folder from the Explorer context menu."},"npm.scriptExplorerAction":{"type":"string","enum":["open","run"],"markdownDescription":"The default click action used in the NPM Scripts Explorer: `open` or `run`, the default is `open`.","scope":"window","default":"open"},"npm.scriptExplorerExclude":{"type":"array","items":{"type":"string"},"markdownDescription":"An array of regular expressions that indicate which scripts should be excluded from the NPM Scripts view.","scope":"resource","default":[]},"npm.fetchOnlinePackageInfo":{"type":"boolean","description":"Fetch data from https://registry.npmjs.org and https://registry.bower.io to provide auto-completion and information on hover features on npm dependencies.","default":true,"scope":"window","tags":["usesOnlineServices"]},"npm.scriptHover":{"type":"boolean","description":"Display hover with 'Run' and 'Debug' commands for scripts.","default":true,"scope":"window"}}},"jsonValidation":[{"fileMatch":"package.json","url":"https://www.schemastore.org/package"},{"fileMatch":"bower.json","url":"https://www.schemastore.org/bower"}],"taskDefinitions":[{"type":"npm","required":["script"],"properties":{"script":{"type":"string","description":"The npm script to customize."},"path":{"type":"string","description":"The path to the folder of the package.json file that provides the script. Can be omitted."}},"when":"shellExecutionSupported"}],"terminalQuickFixes":[{"id":"ms-vscode.npm-command","commandLineMatcher":"npm","commandExitResult":"error","outputMatcher":{"anchor":"bottom","length":8,"lineMatcher":"Did you mean (?:this|one of these)\\?((?:\\n.+?npm .+ #.+)+)","offset":2}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/npm","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.objective-c"},"manifest":{"name":"objective-c","displayName":"Objective-C Language Basics","description":"Provides syntax highlighting and bracket matching in Objective-C files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ./build/update-grammars.js"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"objective-c","extensions":[".m"],"aliases":["Objective-C"],"configuration":"./language-configuration.json"},{"id":"objective-cpp","extensions":[".mm"],"aliases":["Objective-C++"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"objective-c","scopeName":"source.objc","path":"./syntaxes/objective-c.tmLanguage.json"},{"language":"objective-cpp","scopeName":"source.objcpp","path":"./syntaxes/objective-c++.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/objective-c","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.perl"},"manifest":{"name":"perl","displayName":"Perl Language Basics","description":"Provides syntax highlighting and bracket matching in Perl files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin textmate/perl.tmbundle Syntaxes/Perl.plist ./syntaxes/perl.tmLanguage.json Syntaxes/Perl%206.tmLanguage ./syntaxes/perl6.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"perl","aliases":["Perl","perl"],"extensions":[".pl",".pm",".pod",".t",".PL",".psgi"],"firstLine":"^#!.*\\bperl\\b","configuration":"./perl.language-configuration.json"},{"id":"raku","aliases":["Raku","Perl6","perl6"],"extensions":[".raku",".rakumod",".rakutest",".rakudoc",".nqp",".p6",".pl6",".pm6"],"firstLine":"(^#!.*\\bperl6\\b)|use\\s+v6|raku|=begin\\spod|my\\sclass","configuration":"./perl6.language-configuration.json"}],"grammars":[{"language":"perl","scopeName":"source.perl","path":"./syntaxes/perl.tmLanguage.json","unbalancedBracketScopes":["variable.other.predefined.perl"]},{"language":"raku","scopeName":"source.perl.6","path":"./syntaxes/perl6.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/perl","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.php"},"manifest":{"name":"php","displayName":"PHP Language Basics","description":"Provides syntax highlighting and bracket matching for PHP files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"php","extensions":[".php",".php4",".php5",".phtml",".ctp"],"aliases":["PHP","php"],"firstLine":"^#!\\s*/.*\\bphp\\b","mimetypes":["application/x-php"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"php","scopeName":"source.php","path":"./syntaxes/php.tmLanguage.json"},{"language":"php","scopeName":"text.html.php","path":"./syntaxes/html.tmLanguage.json","embeddedLanguages":{"text.html":"html","source.php":"php","source.sql":"sql","text.xml":"xml","source.js":"javascript","source.json":"json","source.css":"css"}}],"snippets":[{"language":"php","path":"./snippets/php.code-snippets"}]},"scripts":{"update-grammar":"node ./build/update-grammar.mjs"},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/php","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.php-language-features"},"manifest":{"name":"php-language-features","displayName":"PHP Language Features","description":"Provides rich language support for PHP files.","version":"1.0.0","publisher":"vscode","license":"MIT","icon":"icons/logo.png","engines":{"vscode":"0.10.x"},"activationEvents":["onLanguage:php"],"main":"./dist/phpMain","categories":["Programming Languages"],"capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":"limited","description":"The extension requires workspace trust when the `php.validate.executablePath` setting will load a version of PHP in the workspace.","restrictedConfigurations":["php.validate.executablePath"]}},"contributes":{"configuration":{"title":"PHP","type":"object","order":20,"properties":{"php.suggest.basic":{"type":"boolean","default":true,"description":"Controls whether the built-in PHP language suggestions are enabled. The support suggests PHP globals and variables."},"php.validate.enable":{"type":"boolean","default":true,"description":"Enable/disable built-in PHP validation."},"php.validate.executablePath":{"type":["string","null"],"default":null,"description":"Points to the PHP executable.","scope":"machine-overridable"},"php.validate.run":{"type":"string","enum":["onSave","onType"],"default":"onSave","description":"Whether the linter is run on save or on type."}}},"jsonValidation":[{"fileMatch":"composer.json","url":"https://getcomposer.org/schema.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/php-language-features","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.powershell"},"manifest":{"name":"powershell","displayName":"Powershell Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in Powershell files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"powershell","extensions":[".ps1",".psm1",".psd1",".pssc",".psrc"],"aliases":["PowerShell","powershell","ps","ps1","pwsh"],"firstLine":"^#!\\s*/.*\\bpwsh\\b","configuration":"./language-configuration.json"}],"grammars":[{"language":"powershell","scopeName":"source.powershell","path":"./syntaxes/powershell.tmLanguage.json"}]},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin PowerShell/EditorSyntax PowerShellSyntax.tmLanguage ./syntaxes/powershell.tmLanguage.json"},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/powershell","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.prompt"},"manifest":{"name":"prompt","displayName":"Prompt Language Basics","description":"Syntax highlighting for Prompt and Instructions documents.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.20.0"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"prompt","aliases":["Prompt","prompt"],"extensions":[".prompt.md"],"configuration":"./language-configuration.json"},{"id":"instructions","aliases":["Instructions","instructions"],"extensions":[".instructions.md","copilot-instructions.md"],"configuration":"./language-configuration.json"},{"id":"chatagent","aliases":["Agent","chat agent"],"extensions":[".agent.md",".chatmode.md"],"filenamePatterns":["**/.github/agents/*.md"],"configuration":"./language-configuration.json"},{"id":"skill","aliases":["Skill","skill"],"filenames":["SKILL.md"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"prompt","path":"./syntaxes/prompt.tmLanguage.json","scopeName":"text.html.markdown.prompt","unbalancedBracketScopes":["markup.underline.link.markdown","punctuation.definition.list.begin.markdown"]},{"language":"instructions","path":"./syntaxes/prompt.tmLanguage.json","scopeName":"text.html.markdown.prompt","unbalancedBracketScopes":["markup.underline.link.markdown","punctuation.definition.list.begin.markdown"]},{"language":"chatagent","path":"./syntaxes/prompt.tmLanguage.json","scopeName":"text.html.markdown.prompt","unbalancedBracketScopes":["markup.underline.link.markdown","punctuation.definition.list.begin.markdown"]},{"language":"skill","path":"./syntaxes/prompt.tmLanguage.json","scopeName":"text.html.markdown.prompt","unbalancedBracketScopes":["markup.underline.link.markdown","punctuation.definition.list.begin.markdown"]}],"configurationDefaults":{"[prompt]":{"editor.insertSpaces":true,"editor.tabSize":2,"editor.autoIndent":"advanced","editor.unicodeHighlight.ambiguousCharacters":false,"editor.unicodeHighlight.invisibleCharacters":false,"diffEditor.ignoreTrimWhitespace":false,"editor.wordWrap":"on","editor.quickSuggestions":{"comments":"off","strings":"on","other":"on"},"editor.wordBasedSuggestions":"off"},"[instructions]":{"editor.insertSpaces":true,"editor.tabSize":2,"editor.autoIndent":"advanced","editor.unicodeHighlight.ambiguousCharacters":false,"editor.unicodeHighlight.invisibleCharacters":false,"diffEditor.ignoreTrimWhitespace":false,"editor.wordWrap":"on","editor.quickSuggestions":{"comments":"off","strings":"on","other":"on"},"editor.wordBasedSuggestions":"off"},"[chatagent]":{"editor.insertSpaces":true,"editor.tabSize":2,"editor.autoIndent":"advanced","editor.unicodeHighlight.ambiguousCharacters":false,"editor.unicodeHighlight.invisibleCharacters":false,"diffEditor.ignoreTrimWhitespace":false,"editor.wordWrap":"on","editor.quickSuggestions":{"comments":"off","strings":"on","other":"on"},"editor.wordBasedSuggestions":"off"},"[skill]":{"editor.insertSpaces":true,"editor.tabSize":2,"editor.autoIndent":"advanced","editor.unicodeHighlight.ambiguousCharacters":false,"editor.unicodeHighlight.invisibleCharacters":false,"diffEditor.ignoreTrimWhitespace":false,"editor.wordWrap":"on","editor.quickSuggestions":{"comments":"off","strings":"on","other":"on"},"editor.wordBasedSuggestions":"off"}}},"scripts":{},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/prompt-basics","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.pug"},"manifest":{"name":"pug","displayName":"Pug Language Basics","description":"Provides syntax highlighting and bracket matching in Pug files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin davidrios/pug-tmbundle Syntaxes/Pug.JSON-tmLanguage ./syntaxes/pug.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"jade","extensions":[".pug",".jade"],"aliases":["Pug","Jade","jade"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"jade","scopeName":"text.pug","path":"./syntaxes/pug.tmLanguage.json"}],"configurationDefaults":{"[jade]":{"diffEditor.ignoreTrimWhitespace":false}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/pug","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.python"},"manifest":{"name":"python","displayName":"Python Language Basics","description":"Provides syntax highlighting, bracket matching and folding in Python files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"python","extensions":[".py",".rpy",".pyw",".cpy",".gyp",".gypi",".pyi",".ipy",".pyt"],"aliases":["Python","py"],"filenames":["SConstruct","SConscript"],"firstLine":"^#!\\s*/?.*\\bpython[0-9.-]*\\b","configuration":"./language-configuration.json"}],"grammars":[{"language":"python","scopeName":"source.python","path":"./syntaxes/MagicPython.tmLanguage.json"},{"scopeName":"source.regexp.python","path":"./syntaxes/MagicRegExp.tmLanguage.json"}],"configurationDefaults":{"[python]":{"diffEditor.ignoreTrimWhitespace":false,"editor.defaultColorDecorators":"never"}}},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin MagicStack/MagicPython grammars/MagicPython.tmLanguage ./syntaxes/MagicPython.tmLanguage.json grammars/MagicRegExp.tmLanguage ./syntaxes/MagicRegExp.tmLanguage.json"},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/python","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.r"},"manifest":{"name":"r","displayName":"R Language Basics","description":"Provides syntax highlighting and bracket matching in R files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin REditorSupport/vscode-R-syntax syntaxes/r.json ./syntaxes/r.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"r","extensions":[".R",".Rhistory",".Rprofile",".rt"],"aliases":["R","r"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"r","scopeName":"source.r","path":"./syntaxes/r.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/r","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.razor"},"manifest":{"name":"razor","displayName":"Razor Language Basics","description":"Provides syntax highlighting, bracket matching and folding in Razor files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"0.10.x"},"scripts":{"update-grammar":"node ./build/update-grammar.mjs"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"razor","extensions":[".cshtml",".razor"],"aliases":["Razor","razor"],"mimetypes":["text/x-cshtml"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"razor","scopeName":"text.html.cshtml","path":"./syntaxes/cshtml.tmLanguage.json","embeddedLanguages":{"section.embedded.source.cshtml":"csharp","source.css":"css","source.js":"javascript"}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/razor","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.references-view"},"manifest":{"name":"references-view","displayName":"Reference Search View","description":"Reference Search results as separate, stable view in the sidebar","icon":"media/icon.png","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.67.0"},"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"repository":{"type":"git","url":"https://github.com/Microsoft/vscode-references-view"},"bugs":{"url":"https://github.com/Microsoft/vscode-references-view/issues"},"activationEvents":["onCommand:references-view.find","onCommand:editor.action.showReferences"],"main":"./dist/extension","browser":"./dist/extension.js","contributes":{"configuration":{"properties":{"references.preferredLocation":{"description":"Controls whether 'Peek References' or 'Find References' is invoked when selecting CodeLens references.","type":"string","default":"peek","enum":["peek","view"],"enumDescriptions":["Show references in peek editor.","Show references in separate view."]}}},"viewsContainers":{"activitybar":[{"id":"references-view","icon":"$(references)","title":"References"}]},"views":{"references-view":[{"id":"references-view.tree","name":"Reference Search Results","when":"reference-list.isActive"}]},"commands":[{"command":"references-view.findReferences","title":"Find All References","category":"References"},{"command":"references-view.findImplementations","title":"Find All Implementations","category":"References"},{"command":"references-view.clearHistory","title":"Clear History","category":"References","icon":"$(clear-all)"},{"command":"references-view.clear","title":"Clear","category":"References","icon":"$(clear-all)"},{"command":"references-view.refresh","title":"Refresh","category":"References","icon":"$(refresh)"},{"command":"references-view.pickFromHistory","title":"Show History","category":"References"},{"command":"references-view.removeReferenceItem","title":"Dismiss","icon":"$(close)"},{"command":"references-view.copy","title":"Copy"},{"command":"references-view.copyAll","title":"Copy All"},{"command":"references-view.copyPath","title":"Copy Path"},{"command":"references-view.refind","title":"Rerun","icon":"$(refresh)"},{"command":"references-view.showCallHierarchy","title":"Show Call Hierarchy","category":"Calls"},{"command":"references-view.showOutgoingCalls","title":"Show Outgoing Calls","category":"Calls","icon":"$(call-incoming)"},{"command":"references-view.showIncomingCalls","title":"Show Incoming Calls","category":"Calls","icon":"$(call-outgoing)"},{"command":"references-view.removeCallItem","title":"Dismiss","icon":"$(close)"},{"command":"references-view.next","title":"Go to Next Reference","enablement":"references-view.canNavigate"},{"command":"references-view.prev","title":"Go to Previous Reference","enablement":"references-view.canNavigate"},{"command":"references-view.showTypeHierarchy","title":"Show Type Hierarchy","category":"Types"},{"command":"references-view.showSupertypes","title":"Show Supertypes","category":"Types","icon":"$(type-hierarchy-super)"},{"command":"references-view.showSubtypes","title":"Show Subtypes","category":"Types","icon":"$(type-hierarchy-sub)"},{"command":"references-view.removeTypeItem","title":"Dismiss","icon":"$(close)"}],"menus":{"editor/context":[{"command":"references-view.findReferences","when":"editorHasReferenceProvider","group":"0_navigation@1"},{"command":"references-view.findImplementations","when":"editorHasImplementationProvider","group":"0_navigation@2"},{"command":"references-view.showCallHierarchy","when":"editorHasCallHierarchyProvider","group":"0_navigation@3"},{"command":"references-view.showTypeHierarchy","when":"editorHasTypeHierarchyProvider","group":"0_navigation@4"}],"view/title":[{"command":"references-view.clear","group":"navigation@3","when":"view == references-view.tree && reference-list.hasResult"},{"command":"references-view.clearHistory","group":"navigation@3","when":"view == references-view.tree && reference-list.hasHistory && !reference-list.hasResult"},{"command":"references-view.refresh","group":"navigation@2","when":"view == references-view.tree && reference-list.hasResult"},{"command":"references-view.showOutgoingCalls","group":"navigation@1","when":"view == references-view.tree && reference-list.hasResult && reference-list.source == callHierarchy && references-view.callHierarchyMode == showIncoming"},{"command":"references-view.showIncomingCalls","group":"navigation@1","when":"view == references-view.tree && reference-list.hasResult && reference-list.source == callHierarchy && references-view.callHierarchyMode == showOutgoing"},{"command":"references-view.showSupertypes","group":"navigation@1","when":"view == references-view.tree && reference-list.hasResult && reference-list.source == typeHierarchy && references-view.typeHierarchyMode != supertypes"},{"command":"references-view.showSubtypes","group":"navigation@1","when":"view == references-view.tree && reference-list.hasResult && reference-list.source == typeHierarchy && references-view.typeHierarchyMode != subtypes"}],"view/item/context":[{"command":"references-view.removeReferenceItem","group":"inline","when":"view == references-view.tree && viewItem == file-item || view == references-view.tree && viewItem == reference-item"},{"command":"references-view.removeCallItem","group":"inline","when":"view == references-view.tree && viewItem == call-item"},{"command":"references-view.removeTypeItem","group":"inline","when":"view == references-view.tree && viewItem == type-item"},{"command":"references-view.refind","group":"inline","when":"view == references-view.tree && viewItem == history-item"},{"command":"references-view.removeReferenceItem","group":"1","when":"view == references-view.tree && viewItem == file-item || view == references-view.tree && viewItem == reference-item"},{"command":"references-view.removeCallItem","group":"1","when":"view == references-view.tree && viewItem == call-item"},{"command":"references-view.removeTypeItem","group":"1","when":"view == references-view.tree && viewItem == type-item"},{"command":"references-view.refind","group":"1","when":"view == references-view.tree && viewItem == history-item"},{"command":"references-view.copy","group":"2@1","when":"view == references-view.tree && viewItem == file-item || view == references-view.tree && viewItem == reference-item"},{"command":"references-view.copyPath","group":"2@2","when":"view == references-view.tree && viewItem == file-item"},{"command":"references-view.copyAll","group":"2@3","when":"view == references-view.tree && viewItem == file-item || view == references-view.tree && viewItem == reference-item"},{"command":"references-view.showOutgoingCalls","group":"1","when":"view == references-view.tree && viewItem == call-item"},{"command":"references-view.showIncomingCalls","group":"1","when":"view == references-view.tree && viewItem == call-item"},{"command":"references-view.showSupertypes","group":"1","when":"view == references-view.tree && viewItem == type-item"},{"command":"references-view.showSubtypes","group":"1","when":"view == references-view.tree && viewItem == type-item"}],"commandPalette":[{"command":"references-view.removeReferenceItem","when":"never"},{"command":"references-view.removeCallItem","when":"never"},{"command":"references-view.removeTypeItem","when":"never"},{"command":"references-view.copy","when":"never"},{"command":"references-view.copyAll","when":"never"},{"command":"references-view.copyPath","when":"never"},{"command":"references-view.refind","when":"never"},{"command":"references-view.findReferences","when":"editorHasReferenceProvider"},{"command":"references-view.clear","when":"reference-list.hasResult"},{"command":"references-view.clearHistory","when":"reference-list.isActive && !reference-list.hasResult"},{"command":"references-view.refresh","when":"reference-list.hasResult"},{"command":"references-view.pickFromHistory","when":"reference-list.isActive"},{"command":"references-view.next","when":"never"},{"command":"references-view.prev","when":"never"}]},"keybindings":[{"command":"references-view.findReferences","when":"editorHasReferenceProvider","key":"shift+alt+f12"},{"command":"references-view.next","when":"reference-list.hasResult","key":"f4"},{"command":"references-view.prev","when":"reference-list.hasResult","key":"shift+f4"},{"command":"references-view.showCallHierarchy","when":"editorHasCallHierarchyProvider","key":"shift+alt+h"}]}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/references-view","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.restructuredtext"},"manifest":{"name":"restructuredtext","displayName":"reStructuredText Language Basics","description":"Provides syntax highlighting in reStructuredText files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin trond-snekvik/vscode-rst syntaxes/rst.tmLanguage.json ./syntaxes/rst.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"restructuredtext","aliases":["reStructuredText"],"configuration":"./language-configuration.json","extensions":[".rst"]}],"grammars":[{"language":"restructuredtext","scopeName":"source.rst","path":"./syntaxes/rst.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/restructuredtext","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.ruby"},"manifest":{"name":"ruby","displayName":"Ruby Language Basics","description":"Provides syntax highlighting and bracket matching in Ruby files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin Shopify/ruby-lsp vscode/grammars/ruby.cson.json ./syntaxes/ruby.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"ruby","extensions":[".rb",".rbx",".rjs",".gemspec",".rake",".ru",".erb",".podspec",".rbi"],"filenames":["rakefile","gemfile","guardfile","podfile","capfile","cheffile","hobofile","vagrantfile","appraisals","rantfile","berksfile","berksfile.lock","thorfile","puppetfile","dangerfile","brewfile","fastfile","appfile","deliverfile","matchfile","scanfile","snapfile","gymfile"],"aliases":["Ruby","rb"],"firstLine":"^#!\\s*/.*\\bruby\\b","configuration":"./language-configuration.json"}],"grammars":[{"language":"ruby","scopeName":"source.ruby","path":"./syntaxes/ruby.tmLanguage.json"}],"configurationDefaults":{"[ruby]":{"editor.defaultColorDecorators":"never"}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/ruby","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.rust"},"manifest":{"name":"rust","displayName":"Rust Language Basics","description":"Provides syntax highlighting and bracket matching in Rust files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ./build/update-grammar.mjs"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"rust","extensions":[".rs"],"aliases":["Rust","rust"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"rust","path":"./syntaxes/rust.tmLanguage.json","scopeName":"source.rust"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/rust","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.scss"},"manifest":{"name":"scss","displayName":"SCSS Language Basics","description":"Provides syntax highlighting, bracket matching and folding in SCSS files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin atom/language-sass grammars/scss.cson ./syntaxes/scss.tmLanguage.json grammars/sassdoc.cson ./syntaxes/sassdoc.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"scss","aliases":["SCSS","scss"],"extensions":[".scss"],"mimetypes":["text/x-scss","text/scss"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"scss","scopeName":"source.css.scss","path":"./syntaxes/scss.tmLanguage.json"},{"scopeName":"source.sassdoc","path":"./syntaxes/sassdoc.tmLanguage.json"}],"problemMatchers":[{"name":"node-sass","label":"Node Sass Compiler","owner":"node-sass","fileLocation":"absolute","pattern":[{"regexp":"^{$"},{"regexp":"\\s*\"status\":\\s\\d+,"},{"regexp":"\\s*\"file\":\\s\"(.*)\",","file":1},{"regexp":"\\s*\"line\":\\s(\\d+),","line":1},{"regexp":"\\s*\"column\":\\s(\\d+),","column":1},{"regexp":"\\s*\"message\":\\s\"(.*)\",","message":1},{"regexp":"\\s*\"formatted\":\\s(.*)"},{"regexp":"^}$"}]}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/scss","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.search-result"},"manifest":{"name":"search-result","displayName":"Search Result","description":"Provides syntax highlighting and language features for tabbed search results.","version":"1.0.0","publisher":"vscode","license":"MIT","icon":"images/icon.png","engines":{"vscode":"^1.39.0"},"main":"./dist/extension.js","browser":"./dist/extension.js","activationEvents":["onLanguage:search-result"],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"enabledApiProposals":["documentFiltersExclusive"],"contributes":{"configurationDefaults":{"[search-result]":{"editor.lineNumbers":"off"}},"languages":[{"id":"search-result","extensions":[".code-search"],"aliases":["Search Result"]}],"grammars":[{"language":"search-result","scopeName":"text.searchResult","path":"./syntaxes/searchResult.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/search-result","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.shaderlab"},"manifest":{"name":"shaderlab","displayName":"Shaderlab Language Basics","description":"Provides syntax highlighting and bracket matching in Shaderlab files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin tgjones/shaders-tmLanguage grammars/shaderlab.json ./syntaxes/shaderlab.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"shaderlab","extensions":[".shader"],"aliases":["ShaderLab","shaderlab"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"shaderlab","path":"./syntaxes/shaderlab.tmLanguage.json","scopeName":"source.shaderlab"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/shaderlab","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.shellscript"},"manifest":{"name":"shellscript","displayName":"Shell Script Language Basics","description":"Provides syntax highlighting and bracket matching in Shell Script files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin jeff-hykin/better-shell-syntax autogenerated/shell.tmLanguage.json ./syntaxes/shell-unix-bash.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"shellscript","aliases":["Shell Script","shellscript","bash","fish","sh","zsh","ksh","csh"],"extensions":[".sh",".bash",".bashrc",".bash_aliases",".bash_profile",".bash_login",".ebuild",".eclass",".profile",".bash_logout",".xprofile",".xsession",".xsessionrc",".Xsession",".zsh",".zshrc",".zprofile",".zlogin",".zlogout",".zshenv",".zsh-theme",".fish",".ksh",".csh",".cshrc",".tcshrc",".yashrc",".yash_profile"],"filenames":["APKBUILD","PKGBUILD",".envrc",".hushlogin","zshrc","zshenv","zlogin","zprofile","zlogout","bashrc_Apple_Terminal","zshrc_Apple_Terminal"],"firstLine":"^#!.*\\b(bash|fish|zsh|sh|ksh|dtksh|pdksh|mksh|ash|dash|yash|sh|csh|jcsh|tcsh|itcsh).*|^#\\s*-\\*-[^*]*mode:\\s*shell-script[^*]*-\\*-","configuration":"./language-configuration.json","mimetypes":["text/x-shellscript"]}],"grammars":[{"language":"shellscript","scopeName":"source.shell","path":"./syntaxes/shell-unix-bash.tmLanguage.json","balancedBracketScopes":["*"],"unbalancedBracketScopes":["meta.scope.case-pattern.shell"]}],"configurationDefaults":{"[shellscript]":{"files.eol":"\n","editor.defaultColorDecorators":"never"}}},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/shellscript","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.simple-browser"},"manifest":{"name":"simple-browser","displayName":"Simple Browser","description":"A very basic built-in webview for displaying web content.","enabledApiProposals":["externalUriOpener"],"version":"1.0.0","icon":"media/icon.png","publisher":"vscode","license":"MIT","aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","engines":{"vscode":"^1.70.0"},"main":"./dist/extension","browser":"./dist/browser/extension","categories":["Other"],"extensionKind":["ui","workspace"],"activationEvents":["onCommand:simpleBrowser.api.open","onOpenExternalUri:http","onOpenExternalUri:https","onWebviewPanel:simpleBrowser.view"],"capabilities":{"virtualWorkspaces":true,"untrustedWorkspaces":{"supported":true}},"contributes":{"commands":[{"command":"simpleBrowser.show","title":"Show","category":"Simple Browser"}],"configuration":[{"title":"Simple Browser","properties":{"simpleBrowser.focusLockIndicator.enabled":{"type":"boolean","default":true,"title":"Focus Lock Indicator Enabled","description":"Enable/disable the floating indicator that shows when focused in the simple browser."},"simpleBrowser.useIntegratedBrowser":{"type":"boolean","default":false,"markdownDescription":"When enabled, the `simpleBrowser.show` command will open URLs in the integrated browser instead of the Simple Browser webview. **Note:** This setting is experimental and only available on desktop.","scope":"application","tags":["experimental","onExP"]}}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/simple-browser","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.sql"},"manifest":{"name":"sql","displayName":"SQL Language Basics","description":"Provides syntax highlighting and bracket matching in SQL files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ./build/update-grammar.mjs"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"sql","extensions":[".sql",".dsql"],"aliases":["MS SQL","T-SQL"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"sql","scopeName":"source.sql","path":"./syntaxes/sql.tmLanguage.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/sql","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.swift"},"manifest":{"name":"swift","displayName":"Swift Language Basics","description":"Provides snippets, syntax highlighting and bracket matching in Swift files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin jtbandes/swift-tmlanguage Swift.tmLanguage.json ./syntaxes/swift.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"swift","aliases":["Swift","swift"],"extensions":[".swift"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"swift","scopeName":"source.swift","path":"./syntaxes/swift.tmLanguage.json"}],"snippets":[{"language":"swift","path":"./snippets/swift.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/swift","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.terminal-suggest"},"manifest":{"name":"terminal-suggest","publisher":"vscode","displayName":"Terminal Suggest for VS Code","description":"Extension to add terminal completions for zsh, bash, and fish terminals.","version":"1.0.1","private":true,"license":"MIT","icon":"./src/media/icon.png","engines":{"vscode":"^1.95.0"},"categories":["Other"],"enabledApiProposals":["terminalCompletionProvider","terminalShellEnv"],"contributes":{"commands":[{"command":"terminal.integrated.suggest.clearCachedGlobals","category":"Terminal","title":"Clear Suggest Cached Globals"}],"terminal":{"completionProviders":[{"description":"Show suggestions for commands, arguments, flags, and file paths based upon the Fig spec."}]}},"main":"./dist/terminalSuggestMain","activationEvents":["onTerminalShellIntegration:*"],"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/terminal-suggest","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-2026"},"manifest":{"name":"theme-2026","displayName":"2026 Themes","description":"Modern, minimal light and dark themes for 2026 with consistent neutral palette and accessible color contrast","version":"0.1.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.85.0"},"enabledApiProposals":["css"],"categories":["Themes"],"contributes":{"themes":[{"id":"Experimental Light","label":"VS Code Light","uiTheme":"vs","path":"./themes/2026-light.json"},{"id":"Experimental Dark","label":"VS Code Dark","uiTheme":"vs-dark","path":"./themes/2026-dark.json"}],"css":[{"path":"./themes/styles.css"}]}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-2026","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-abyss"},"manifest":{"name":"theme-abyss","displayName":"Abyss Theme","description":"Abyss theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Abyss","label":"Abyss","uiTheme":"vs-dark","path":"./themes/abyss-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-abyss","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-defaults"},"manifest":{"name":"theme-defaults","displayName":"Default Themes","description":"The default Visual Studio light and dark themes","categories":["Themes"],"version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"contributes":{"themes":[{"id":"Default Dark+","label":"Dark+","uiTheme":"vs-dark","path":"./themes/dark_plus.json"},{"id":"Default Dark Modern","label":"Dark Modern","uiTheme":"vs-dark","path":"./themes/dark_modern.json"},{"id":"Default Light+","label":"Light+","uiTheme":"vs","path":"./themes/light_plus.json"},{"id":"Default Light Modern","label":"Light Modern","uiTheme":"vs","path":"./themes/light_modern.json"},{"id":"Visual Studio Dark","label":"Dark (Visual Studio)","uiTheme":"vs-dark","path":"./themes/dark_vs.json"},{"id":"Visual Studio Light","label":"Light (Visual Studio)","uiTheme":"vs","path":"./themes/light_vs.json"},{"id":"Default High Contrast","label":"Dark High Contrast","uiTheme":"hc-black","path":"./themes/hc_black.json"},{"id":"Default High Contrast Light","label":"Light High Contrast","uiTheme":"hc-light","path":"./themes/hc_light.json"}],"iconThemes":[{"id":"vs-minimal","label":"Minimal (Visual Studio Code)","path":"./fileicons/vs_minimal-icon-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-defaults","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-kimbie-dark"},"manifest":{"name":"theme-kimbie-dark","displayName":"Kimbie Dark Theme","description":"Kimbie dark theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Kimbie Dark","label":"Kimbie Dark","uiTheme":"vs-dark","path":"./themes/kimbie-dark-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-kimbie-dark","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-monokai"},"manifest":{"name":"theme-monokai","displayName":"Monokai Theme","description":"Monokai theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Monokai","label":"Monokai","uiTheme":"vs-dark","path":"./themes/monokai-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-monokai","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-monokai-dimmed"},"manifest":{"name":"theme-monokai-dimmed","displayName":"Monokai Dimmed Theme","description":"Monokai dimmed theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Monokai Dimmed","label":"Monokai Dimmed","uiTheme":"vs-dark","path":"./themes/dimmed-monokai-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-monokai-dimmed","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-quietlight"},"manifest":{"name":"theme-quietlight","displayName":"Quiet Light Theme","description":"Quiet light theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Quiet Light","label":"Quiet Light","uiTheme":"vs","path":"./themes/quietlight-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-quietlight","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-red"},"manifest":{"name":"theme-red","displayName":"Red Theme","description":"Red theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Red","label":"Red","uiTheme":"vs-dark","path":"./themes/Red-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-red","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.vscode-theme-seti"},"manifest":{"name":"vscode-theme-seti","private":true,"version":"1.0.0","displayName":"Seti File Icon Theme","description":"A file icon theme made out of the Seti UI file icons","publisher":"vscode","license":"MIT","icon":"icons/seti-circular-128x128.png","scripts":{"update":"node ./build/update-icon-theme.js"},"engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"iconThemes":[{"id":"vs-seti","label":"Seti (Visual Studio Code)","path":"./icons/vs-seti-icon-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-seti","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-solarized-dark"},"manifest":{"name":"theme-solarized-dark","displayName":"Solarized Dark Theme","description":"Solarized dark theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Solarized Dark","label":"Solarized Dark","uiTheme":"vs-dark","path":"./themes/solarized-dark-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-solarized-dark","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-solarized-light"},"manifest":{"name":"theme-solarized-light","displayName":"Solarized Light Theme","description":"Solarized light theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Solarized Light","label":"Solarized Light","uiTheme":"vs","path":"./themes/solarized-light-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-solarized-light","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.theme-tomorrow-night-blue"},"manifest":{"name":"theme-tomorrow-night-blue","displayName":"Tomorrow Night Blue Theme","description":"Tomorrow night blue theme for Visual Studio Code","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Themes"],"contributes":{"themes":[{"id":"Tomorrow Night Blue","label":"Tomorrow Night Blue","uiTheme":"vs-dark","path":"./themes/tomorrow-night-blue-color-theme.json"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/theme-tomorrow-night-blue","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.tunnel-forwarding"},"manifest":{"name":"tunnel-forwarding","displayName":"Local Tunnel Port Forwarding","description":"Allows forwarding local ports to be accessible over the internet.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"^1.82.0"},"icon":"media/icon.png","capabilities":{"virtualWorkspaces":false,"untrustedWorkspaces":{"supported":true}},"enabledApiProposals":["resolvers","tunnelFactory"],"activationEvents":["onTunnel"],"contributes":{"commands":[{"category":"Port Forwarding","command":"tunnel-forwarding.showLog","title":"Show Log","enablement":"tunnelForwardingHasLog"},{"category":"Port Forwarding","command":"tunnel-forwarding.restart","title":"Restart Forwarding System","enablement":"tunnelForwardingIsRunning"}]},"main":"./dist/extension","prettier":{"printWidth":100,"trailingComma":"all","singleQuote":true,"arrowParens":"avoid"},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/tunnel-forwarding","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.typescript"},"manifest":{"name":"typescript","description":"Provides snippets, syntax highlighting, bracket matching and folding in TypeScript files.","displayName":"TypeScript Language Basics","version":"1.0.0","author":"vscode","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ./build/update-grammars.mjs"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"typescript","aliases":["TypeScript","ts","typescript"],"extensions":[".ts",".cts",".mts"],"firstLine":"^#!.*\\b(deno|bun|ts-node)\\b","configuration":"./language-configuration.json"},{"id":"typescriptreact","aliases":["TypeScript JSX","TypeScript React","tsx"],"extensions":[".tsx"],"configuration":"./language-configuration.json"},{"id":"jsonc","filenames":["tsconfig.json","jsconfig.json"],"filenamePatterns":["tsconfig.*.json","jsconfig.*.json","tsconfig-*.json","jsconfig-*.json"]},{"id":"json","extensions":[".tsbuildinfo"]}],"grammars":[{"language":"typescript","scopeName":"source.ts","path":"./syntaxes/TypeScript.tmLanguage.json","unbalancedBracketScopes":["keyword.operator.relational","storage.type.function.arrow","keyword.operator.bitwise.shift","meta.brace.angle","punctuation.definition.tag","keyword.operator.assignment.compound.bitwise.ts"],"tokenTypes":{"punctuation.definition.template-expression":"other","entity.name.type.instance.jsdoc":"other","entity.name.function.tagged-template":"other","meta.import string.quoted":"other","variable.other.jsdoc":"other"}},{"language":"typescriptreact","scopeName":"source.tsx","path":"./syntaxes/TypeScriptReact.tmLanguage.json","unbalancedBracketScopes":["keyword.operator.relational","storage.type.function.arrow","keyword.operator.bitwise.shift","punctuation.definition.tag","keyword.operator.assignment.compound.bitwise.ts"],"embeddedLanguages":{"meta.tag.tsx":"jsx-tags","meta.tag.without-attributes.tsx":"jsx-tags","meta.tag.attributes.tsx":"typescriptreact","meta.embedded.expression.tsx":"typescriptreact"},"tokenTypes":{"punctuation.definition.template-expression":"other","entity.name.type.instance.jsdoc":"other","entity.name.function.tagged-template":"other","meta.import string.quoted":"other","variable.other.jsdoc":"other"}},{"scopeName":"documentation.injection.ts","path":"./syntaxes/jsdoc.ts.injection.tmLanguage.json","injectTo":["source.ts","source.tsx"]},{"scopeName":"documentation.injection.js.jsx","path":"./syntaxes/jsdoc.js.injection.tmLanguage.json","injectTo":["source.js","source.js.jsx"]}],"semanticTokenScopes":[{"language":"typescript","scopes":{"property":["variable.other.property.ts"],"property.readonly":["variable.other.constant.property.ts"],"variable":["variable.other.readwrite.ts"],"variable.readonly":["variable.other.constant.object.ts"],"function":["entity.name.function.ts"],"namespace":["entity.name.type.module.ts"],"variable.defaultLibrary":["support.variable.ts"],"function.defaultLibrary":["support.function.ts"]}},{"language":"typescriptreact","scopes":{"property":["variable.other.property.tsx"],"property.readonly":["variable.other.constant.property.tsx"],"variable":["variable.other.readwrite.tsx"],"variable.readonly":["variable.other.constant.object.tsx"],"function":["entity.name.function.tsx"],"namespace":["entity.name.type.module.tsx"],"variable.defaultLibrary":["support.variable.tsx"],"function.defaultLibrary":["support.function.tsx"]}}],"snippets":[{"language":"typescript","path":"./snippets/typescript.code-snippets"},{"language":"typescriptreact","path":"./snippets/typescript.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/typescript-basics","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.typescript-language-features"},"manifest":{"name":"typescript-language-features","description":"Provides rich language support for JavaScript and TypeScript.","displayName":"TypeScript and JavaScript Language Features","version":"1.0.0","author":"vscode","publisher":"vscode","license":"MIT","aiKey":"0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255","enabledApiProposals":["workspaceTrust","multiDocumentHighlightProvider","codeActionAI","codeActionRanges","editorHoverVerbosityLevel"],"capabilities":{"virtualWorkspaces":{"supported":"limited","description":"In virtual workspaces, resolving and finding references across files is not supported."},"untrustedWorkspaces":{"supported":"limited","description":"The extension requires workspace trust when the workspace version is used because it executes code specified by the workspace.","restrictedConfigurations":["typescript.tsdk","typescript.tsserver.pluginPaths","typescript.npm","typescript.tsserver.nodePath"]}},"engines":{"vscode":"^1.30.0"},"icon":"media/icon.png","categories":["Programming Languages"],"activationEvents":["onLanguage:javascript","onLanguage:javascriptreact","onLanguage:typescript","onLanguage:typescriptreact","onLanguage:jsx-tags","onCommand:typescript.tsserverRequest","onCommand:_typescript.configurePlugin","onCommand:_typescript.learnMoreAboutRefactorings","onCommand:typescript.fileReferences","onTaskType:typescript","onLanguage:jsonc","onWalkthrough:nodejsWelcome"],"main":"./dist/extension","browser":"./dist/browser/extension","contributes":{"jsonValidation":[{"fileMatch":"package.json","url":"./schemas/package.schema.json"},{"fileMatch":"tsconfig.json","url":"https://www.schemastore.org/tsconfig"},{"fileMatch":"tsconfig.json","url":"./schemas/tsconfig.schema.json"},{"fileMatch":"tsconfig.*.json","url":"https://www.schemastore.org/tsconfig"},{"fileMatch":"tsconfig-*.json","url":"./schemas/tsconfig.schema.json"},{"fileMatch":"tsconfig-*.json","url":"https://www.schemastore.org/tsconfig"},{"fileMatch":"tsconfig.*.json","url":"./schemas/tsconfig.schema.json"},{"fileMatch":"typings.json","url":"https://www.schemastore.org/typings"},{"fileMatch":".bowerrc","url":"https://www.schemastore.org/bowerrc"},{"fileMatch":".babelrc","url":"https://www.schemastore.org/babelrc"},{"fileMatch":".babelrc.json","url":"https://www.schemastore.org/babelrc"},{"fileMatch":"babel.config.json","url":"https://www.schemastore.org/babelrc"},{"fileMatch":"jsconfig.json","url":"https://www.schemastore.org/jsconfig"},{"fileMatch":"jsconfig.json","url":"./schemas/jsconfig.schema.json"},{"fileMatch":"jsconfig.*.json","url":"https://www.schemastore.org/jsconfig"},{"fileMatch":"jsconfig.*.json","url":"./schemas/jsconfig.schema.json"},{"fileMatch":".swcrc","url":"https://swc.rs/schema.json"},{"fileMatch":"typedoc.json","url":"https://typedoc.org/schema.json"}],"configuration":[{"type":"object","order":20,"properties":{"typescript.tsdk":{"type":"string","markdownDescription":"Specifies the folder path to the tsserver and `lib*.d.ts` files under a TypeScript install to use for IntelliSense, for example: `./node_modules/typescript/lib`.\n\n- When specified as a user setting, the TypeScript version from `typescript.tsdk` automatically replaces the built-in TypeScript version.\n- When specified as a workspace setting, `typescript.tsdk` allows you to switch to use that workspace version of TypeScript for IntelliSense with the `TypeScript: Select TypeScript version` command.\n\nSee the [TypeScript documentation](https://code.visualstudio.com/docs/typescript/typescript-compiling#_using-newer-typescript-versions) for more detail about managing TypeScript versions.","scope":"window"},"typescript.disableAutomaticTypeAcquisition":{"type":"boolean","default":false,"markdownDescription":"Disables [automatic type acquisition](https://code.visualstudio.com/docs/nodejs/working-with-javascript#_typings-and-automatic-type-acquisition). Automatic type acquisition fetches `@types` packages from npm to improve IntelliSense for external libraries.","scope":"window","tags":["usesOnlineServices"]},"typescript.enablePromptUseWorkspaceTsdk":{"type":"boolean","default":false,"description":"Enables prompting of users to use the TypeScript version configured in the workspace for Intellisense.","scope":"window"},"javascript.referencesCodeLens.enabled":{"type":"boolean","default":false,"description":"Enable/disable references CodeLens in JavaScript files.","scope":"window"},"javascript.referencesCodeLens.showOnAllFunctions":{"type":"boolean","default":false,"description":"Enable/disable references CodeLens on all functions in JavaScript files.","scope":"window"},"typescript.referencesCodeLens.enabled":{"type":"boolean","default":false,"description":"Enable/disable references CodeLens in TypeScript files.","scope":"window"},"typescript.referencesCodeLens.showOnAllFunctions":{"type":"boolean","default":false,"description":"Enable/disable references CodeLens on all functions in TypeScript files.","scope":"window"},"typescript.implementationsCodeLens.enabled":{"type":"boolean","default":false,"description":"Enable/disable implementations CodeLens. This CodeLens shows the implementers of an interface.","scope":"window"},"typescript.experimental.useTsgo":{"type":"boolean","default":false,"markdownDescription":"Disables TypeScript and JavaScript language features to allow usage of the TypeScript Go experimental extension. Requires TypeScript Go to be installed and configured. Requires reloading extensions after changing this setting.","scope":"window","tags":["experimental"]},"typescript.implementationsCodeLens.showOnInterfaceMethods":{"type":"boolean","default":false,"description":"Enable/disable implementations CodeLens on interface methods.","scope":"window"},"typescript.implementationsCodeLens.showOnAllClassMethods":{"type":"boolean","default":false,"description":"Enable/disable showing implementations CodeLens above all class methods instead of only on abstract methods.","scope":"window"},"typescript.reportStyleChecksAsWarnings":{"type":"boolean","default":true,"description":"Report style checks as warnings.","scope":"window"},"typescript.validate.enable":{"type":"boolean","default":true,"description":"Enable/disable TypeScript validation.","scope":"window"},"javascript.validate.enable":{"type":"boolean","default":true,"description":"Enable/disable JavaScript validation.","scope":"window"},"js/ts.implicitProjectConfig.module":{"type":"string","markdownDescription":"Sets the module system for the program. See more: https://www.typescriptlang.org/tsconfig#module.","default":"ESNext","enum":["CommonJS","AMD","System","UMD","ES6","ES2015","ES2020","ESNext","None","ES2022","Node12","NodeNext"],"scope":"window"},"js/ts.implicitProjectConfig.target":{"type":"string","default":"ES2024","markdownDescription":"Set target JavaScript language version for emitted JavaScript and include library declarations. See more: https://www.typescriptlang.org/tsconfig#target.","enum":["ES3","ES5","ES6","ES2015","ES2016","ES2017","ES2018","ES2019","ES2020","ES2021","ES2022","ES2023","ES2024","ESNext"],"scope":"window"},"js/ts.implicitProjectConfig.checkJs":{"type":"boolean","default":false,"markdownDescription":"Enable/disable semantic checking of JavaScript files. Existing `jsconfig.json` or `tsconfig.json` files override this setting.","scope":"window"},"js/ts.implicitProjectConfig.experimentalDecorators":{"type":"boolean","default":false,"markdownDescription":"Enable/disable `experimentalDecorators` in JavaScript files that are not part of a project. Existing `jsconfig.json` or `tsconfig.json` files override this setting.","scope":"window"},"js/ts.implicitProjectConfig.strictNullChecks":{"type":"boolean","default":true,"markdownDescription":"Enable/disable [strict null checks](https://www.typescriptlang.org/tsconfig#strictNullChecks) in JavaScript and TypeScript files that are not part of a project. Existing `jsconfig.json` or `tsconfig.json` files override this setting.","scope":"window"},"js/ts.implicitProjectConfig.strictFunctionTypes":{"type":"boolean","default":true,"markdownDescription":"Enable/disable [strict function types](https://www.typescriptlang.org/tsconfig#strictFunctionTypes) in JavaScript and TypeScript files that are not part of a project. Existing `jsconfig.json` or `tsconfig.json` files override this setting.","scope":"window"},"js/ts.implicitProjectConfig.strict":{"type":"boolean","default":true,"markdownDescription":"Enable/disable [strict mode](https://www.typescriptlang.org/tsconfig#strict) in JavaScript and TypeScript files that are not part of a project. Existing `jsconfig.json` or `tsconfig.json` files override this setting.","scope":"window"},"typescript.tsc.autoDetect":{"type":"string","default":"on","enum":["on","off","build","watch"],"markdownEnumDescriptions":["Create both build and watch tasks.","Disable this feature.","Only create single run compile tasks.","Only create compile and watch tasks."],"description":"Controls auto detection of tsc tasks.","scope":"window"},"typescript.locale":{"type":"string","default":"auto","enum":["auto","de","es","en","fr","it","ja","ko","ru","zh-CN","zh-TW"],"enumDescriptions":["Use VS Code's configured display language.","Deutsch","español","English","français","italiano","日本語","한국어","русский","中文(简体)","中文(繁體)"],"markdownDescription":"Sets the locale used to report JavaScript and TypeScript errors. Defaults to use VS Code's locale.","scope":"window"},"javascript.suggestionActions.enabled":{"type":"boolean","default":true,"description":"Enable/disable suggestion diagnostics for JavaScript files in the editor.","scope":"resource"},"typescript.suggestionActions.enabled":{"type":"boolean","default":true,"description":"Enable/disable suggestion diagnostics for TypeScript files in the editor.","scope":"resource"},"typescript.updateImportsOnFileMove.enabled":{"type":"string","enum":["prompt","always","never"],"markdownEnumDescriptions":["Prompt on each rename.","Always update paths automatically.","Never rename paths and don't prompt."],"default":"prompt","description":"Enable/disable automatic updating of import paths when you rename or move a file in VS Code.","scope":"resource"},"javascript.updateImportsOnFileMove.enabled":{"type":"string","enum":["prompt","always","never"],"markdownEnumDescriptions":["Prompt on each rename.","Always update paths automatically.","Never rename paths and don't prompt."],"default":"prompt","description":"Enable/disable automatic updating of import paths when you rename or move a file in VS Code.","scope":"resource"},"typescript.autoClosingTags":{"type":"boolean","default":true,"description":"Enable/disable automatic closing of JSX tags.","scope":"language-overridable"},"javascript.autoClosingTags":{"type":"boolean","default":true,"description":"Enable/disable automatic closing of JSX tags.","scope":"language-overridable"},"typescript.workspaceSymbols.scope":{"type":"string","enum":["allOpenProjects","currentProject"],"enumDescriptions":["Search all open JavaScript or TypeScript projects for symbols.","Only search for symbols in the current JavaScript or TypeScript project."],"default":"allOpenProjects","markdownDescription":"Controls which files are searched by [Go to Symbol in Workspace](https://code.visualstudio.com/docs/editor/editingevolved#_open-symbol-by-name).","scope":"window"},"typescript.preferGoToSourceDefinition":{"type":"boolean","default":false,"description":"Makes `Go to Definition` avoid type declaration files when possible by triggering `Go to Source Definition` instead. This allows `Go to Source Definition` to be triggered with the mouse gesture.","scope":"window"},"javascript.preferGoToSourceDefinition":{"type":"boolean","default":false,"description":"Makes `Go to Definition` avoid type declaration files when possible by triggering `Go to Source Definition` instead. This allows `Go to Source Definition` to be triggered with the mouse gesture.","scope":"window"},"typescript.workspaceSymbols.excludeLibrarySymbols":{"type":"boolean","default":true,"markdownDescription":"Exclude symbols that come from library files in `Go to Symbol in Workspace` results. Requires using TypeScript 5.3+ in the workspace.","scope":"window"},"typescript.tsserver.enableRegionDiagnostics":{"type":"boolean","default":true,"description":"Enables region-based diagnostics in TypeScript. Requires using TypeScript 5.6+ in the workspace.","scope":"window"},"javascript.updateImportsOnPaste.enabled":{"scope":"window","type":"boolean","default":true,"markdownDescription":"Automatically update imports when pasting code. Requires TypeScript 5.6+."},"typescript.updateImportsOnPaste.enabled":{"scope":"window","type":"boolean","default":true,"markdownDescription":"Automatically update imports when pasting code. Requires TypeScript 5.6+."},"js/ts.hover.maximumLength":{"type":"number","default":500,"description":"The maximum number of characters in a hover. If the hover is longer than this, it will be truncated. Requires TypeScript 5.9+.","scope":"resource"}}},{"type":"object","title":"Suggestions","order":21,"properties":{"javascript.suggest.enabled":{"type":"boolean","default":true,"description":"Enable/disable autocomplete suggestions.","scope":"language-overridable"},"typescript.suggest.enabled":{"type":"boolean","default":true,"description":"Enable/disable autocomplete suggestions.","scope":"language-overridable"},"javascript.suggest.autoImports":{"type":"boolean","default":true,"description":"Enable/disable auto import suggestions.","scope":"resource"},"typescript.suggest.autoImports":{"type":"boolean","default":true,"description":"Enable/disable auto import suggestions.","scope":"resource"},"javascript.suggest.names":{"type":"boolean","default":true,"markdownDescription":"Enable/disable including unique names from the file in JavaScript suggestions. Note that name suggestions are always disabled in JavaScript code that is semantically checked using `@ts-check` or `checkJs`.","scope":"resource"},"javascript.suggest.completeFunctionCalls":{"type":"boolean","default":false,"description":"Complete functions with their parameter signature.","scope":"resource"},"typescript.suggest.completeFunctionCalls":{"type":"boolean","default":false,"description":"Complete functions with their parameter signature.","scope":"resource"},"javascript.suggest.paths":{"type":"boolean","default":true,"description":"Enable/disable suggestions for paths in import statements and require calls.","scope":"resource"},"typescript.suggest.paths":{"type":"boolean","default":true,"description":"Enable/disable suggestions for paths in import statements and require calls.","scope":"resource"},"javascript.suggest.completeJSDocs":{"type":"boolean","default":true,"description":"Enable/disable suggestion to complete JSDoc comments.","scope":"language-overridable"},"typescript.suggest.completeJSDocs":{"type":"boolean","default":true,"description":"Enable/disable suggestion to complete JSDoc comments.","scope":"language-overridable"},"javascript.suggest.jsdoc.generateReturns":{"type":"boolean","default":true,"markdownDescription":"Enable/disable generating `@returns` annotations for JSDoc templates.","scope":"language-overridable"},"typescript.suggest.jsdoc.generateReturns":{"type":"boolean","default":true,"markdownDescription":"Enable/disable generating `@returns` annotations for JSDoc templates.","scope":"language-overridable"},"javascript.suggest.includeAutomaticOptionalChainCompletions":{"type":"boolean","default":true,"description":"Enable/disable showing completions on potentially undefined values that insert an optional chain call. Requires strict null checks to be enabled.","scope":"resource"},"typescript.suggest.includeAutomaticOptionalChainCompletions":{"type":"boolean","default":true,"description":"Enable/disable showing completions on potentially undefined values that insert an optional chain call. Requires strict null checks to be enabled.","scope":"resource"},"javascript.suggest.includeCompletionsForImportStatements":{"type":"boolean","default":true,"description":"Enable/disable auto-import-style completions on partially-typed import statements.","scope":"resource"},"typescript.suggest.includeCompletionsForImportStatements":{"type":"boolean","default":true,"description":"Enable/disable auto-import-style completions on partially-typed import statements.","scope":"resource"},"javascript.suggest.classMemberSnippets.enabled":{"type":"boolean","default":true,"description":"Enable/disable snippet completions for class members.","scope":"resource"},"typescript.suggest.classMemberSnippets.enabled":{"type":"boolean","default":true,"description":"Enable/disable snippet completions for class members.","scope":"resource"},"typescript.suggest.objectLiteralMethodSnippets.enabled":{"type":"boolean","default":true,"description":"Enable/disable snippet completions for methods in object literals.","scope":"resource"}}},{"type":"object","title":"Preferences","order":21,"properties":{"javascript.preferences.quoteStyle":{"type":"string","enum":["auto","single","double"],"default":"auto","markdownDescription":"Preferred quote style to use for Quick Fixes.","markdownEnumDescriptions":["Infer quote type from existing code","Always use single quotes: `'`","Always use double quotes: `\"`"],"scope":"language-overridable"},"typescript.preferences.quoteStyle":{"type":"string","enum":["auto","single","double"],"default":"auto","markdownDescription":"Preferred quote style to use for Quick Fixes.","markdownEnumDescriptions":["Infer quote type from existing code","Always use single quotes: `'`","Always use double quotes: `\"`"],"scope":"language-overridable"},"javascript.preferences.importModuleSpecifier":{"type":"string","enum":["shortest","relative","non-relative","project-relative"],"markdownEnumDescriptions":["Prefers a non-relative import only if one is available that has fewer path segments than a relative import.","Prefers a relative path to the imported file location.","Prefers a non-relative import based on the `baseUrl` or `paths` configured in your `jsconfig.json` / `tsconfig.json`.","Prefers a non-relative import only if the relative import path would leave the package or project directory."],"default":"shortest","description":"Preferred path style for auto imports.","scope":"language-overridable"},"typescript.preferences.importModuleSpecifier":{"type":"string","enum":["shortest","relative","non-relative","project-relative"],"markdownEnumDescriptions":["Prefers a non-relative import only if one is available that has fewer path segments than a relative import.","Prefers a relative path to the imported file location.","Prefers a non-relative import based on the `baseUrl` or `paths` configured in your `jsconfig.json` / `tsconfig.json`.","Prefers a non-relative import only if the relative import path would leave the package or project directory."],"default":"shortest","description":"Preferred path style for auto imports.","scope":"language-overridable"},"javascript.preferences.importModuleSpecifierEnding":{"type":"string","enum":["auto","minimal","index","js"],"enumItemLabels":[null,null,null,".js / .ts"],"markdownEnumDescriptions":["Use project settings to select a default.","Shorten `./component/index.js` to `./component`.","Shorten `./component/index.js` to `./component/index`.","Do not shorten path endings; include the `.js` or `.ts` extension."],"default":"auto","description":"Preferred path ending for auto imports.","scope":"language-overridable"},"typescript.preferences.importModuleSpecifierEnding":{"type":"string","enum":["auto","minimal","index","js"],"enumItemLabels":[null,null,null,".js / .ts"],"markdownEnumDescriptions":["Use project settings to select a default.","Shorten `./component/index.js` to `./component`.","Shorten `./component/index.js` to `./component/index`.","Do not shorten path endings; include the `.js` or `.ts` extension."],"default":"auto","description":"Preferred path ending for auto imports.","scope":"language-overridable"},"javascript.preferences.jsxAttributeCompletionStyle":{"type":"string","enum":["auto","braces","none"],"markdownEnumDescriptions":["Insert `={}` or `=\"\"` after attribute names based on the prop type. See `#javascript.preferences.quoteStyle#` to control the type of quotes used for string attributes.","Insert `={}` after attribute names.","Only insert attribute names."],"default":"auto","description":"Preferred style for JSX attribute completions.","scope":"language-overridable"},"typescript.preferences.jsxAttributeCompletionStyle":{"type":"string","enum":["auto","braces","none"],"markdownEnumDescriptions":["Insert `={}` or `=\"\"` after attribute names based on the prop type. See `#typescript.preferences.quoteStyle#` to control the type of quotes used for string attributes.","Insert `={}` after attribute names.","Only insert attribute names."],"default":"auto","description":"Preferred style for JSX attribute completions.","scope":"language-overridable"},"typescript.preferences.includePackageJsonAutoImports":{"type":"string","enum":["auto","on","off"],"enumDescriptions":["Search dependencies based on estimated performance impact.","Always search dependencies.","Never search dependencies."],"default":"auto","markdownDescription":"Enable/disable searching `package.json` dependencies for available auto imports.","scope":"window"},"javascript.preferences.autoImportFileExcludePatterns":{"type":"array","items":{"type":"string"},"markdownDescription":"Specify glob patterns of files to exclude from auto imports. Relative paths are resolved relative to the workspace root. Patterns are evaluated using tsconfig.json [`exclude`](https://www.typescriptlang.org/tsconfig#exclude) semantics.","scope":"resource"},"typescript.preferences.autoImportFileExcludePatterns":{"type":"array","items":{"type":"string"},"markdownDescription":"Specify glob patterns of files to exclude from auto imports. Relative paths are resolved relative to the workspace root. Patterns are evaluated using tsconfig.json [`exclude`](https://www.typescriptlang.org/tsconfig#exclude) semantics.","scope":"resource"},"javascript.preferences.autoImportSpecifierExcludeRegexes":{"type":"array","items":{"type":"string"},"markdownDescription":"Specify regular expressions to exclude auto imports with matching import specifiers. Examples:\n\n- `^node:`\n- `lib/internal` (slashes don't need to be escaped...)\n- `/lib\\/internal/i` (...unless including surrounding slashes for `i` or `u` flags)\n- `^lodash$` (only allow subpath imports from lodash)","scope":"resource"},"typescript.preferences.autoImportSpecifierExcludeRegexes":{"type":"array","items":{"type":"string"},"markdownDescription":"Specify regular expressions to exclude auto imports with matching import specifiers. Examples:\n\n- `^node:`\n- `lib/internal` (slashes don't need to be escaped...)\n- `/lib\\/internal/i` (...unless including surrounding slashes for `i` or `u` flags)\n- `^lodash$` (only allow subpath imports from lodash)","scope":"resource"},"typescript.preferences.preferTypeOnlyAutoImports":{"type":"boolean","default":false,"markdownDescription":"Include the `type` keyword in auto-imports whenever possible. Requires using TypeScript 5.3+ in the workspace.","scope":"resource"},"javascript.preferences.useAliasesForRenames":{"type":"boolean","default":true,"description":"Enable/disable introducing aliases for object shorthand properties during renames.","scope":"language-overridable"},"typescript.preferences.useAliasesForRenames":{"type":"boolean","default":true,"description":"Enable/disable introducing aliases for object shorthand properties during renames.","scope":"language-overridable"},"javascript.preferences.renameMatchingJsxTags":{"type":"boolean","default":true,"description":"When on a JSX tag, try to rename the matching tag instead of renaming the symbol. Requires using TypeScript 5.1+ in the workspace.","scope":"language-overridable"},"typescript.preferences.renameMatchingJsxTags":{"type":"boolean","default":true,"description":"When on a JSX tag, try to rename the matching tag instead of renaming the symbol. Requires using TypeScript 5.1+ in the workspace.","scope":"language-overridable"},"javascript.preferences.organizeImports":{"type":"object","markdownDescription":"Advanced preferences that control how imports are ordered.","properties":{"caseSensitivity":{"type":"string","markdownDescription":"Specifies how imports should be sorted with regards to case-sensitivity. If `auto` or unspecified, we will detect the case-sensitivity per file","enum":["auto","caseInsensitive","caseSensitive"],"markdownEnumDescriptions":["Detect case-sensitivity for import sorting.","%typescript.preferences.organizeImports.caseSensitivity.insensitive","Sort imports case-sensitively."],"default":"auto"},"typeOrder":{"type":"string","markdownDescription":"Specify how type-only named imports should be sorted.","enum":["auto","last","inline","first"],"default":"auto","markdownEnumDescriptions":["Detect where type-only named imports should be sorted.","Type only named imports are sorted to the end of the import list. E.g. `import { B, Z, type A, type Y } from 'module';`","Named imports are sorted by name only. E.g. `import { type A, B, type Y, Z } from 'module';`","Type only named imports are sorted to the beginning of the import list. E.g. `import { type A, type Y, B, Z } from 'module';`"]},"unicodeCollation":{"type":"string","markdownDescription":"Specify whether to sort imports using Unicode or Ordinal collation.","enum":["ordinal","unicode"],"markdownEnumDescriptions":["Sort imports using the numeric value of each code point.","Sort imports using the Unicode code collation."],"default":"ordinal"},"locale":{"type":"string","markdownDescription":"Requires `organizeImports.unicodeCollation: 'unicode'`. Overrides the locale used for collation. Specify `auto` to use the UI locale."},"numericCollation":{"type":"boolean","markdownDescription":"Requires `organizeImports.unicodeCollation: 'unicode'`. Sort numeric strings by integer value."},"accentCollation":{"type":"boolean","markdownDescription":"Requires `organizeImports.unicodeCollation: 'unicode'`. Compare characters with diacritical marks as unequal to base character."},"caseFirst":{"type":"string","markdownDescription":"Requires `organizeImports.unicodeCollation: 'unicode'`, and `organizeImports.caseSensitivity` is not `caseInsensitive`. Indicates whether upper-case will sort before lower-case.","enum":["default","upper","lower"],"markdownEnumDescriptions":["Default order given by `locale`.","Upper-case comes before lower-case. E.g. ` A, a, B, b`.","Lower-case comes before upper-case. E.g.` a, A, z, Z`."],"default":"default"}}},"typescript.preferences.organizeImports":{"type":"object","markdownDescription":"Advanced preferences that control how imports are ordered.","properties":{"caseSensitivity":{"type":"string","markdownDescription":"Specifies how imports should be sorted with regards to case-sensitivity. If `auto` or unspecified, we will detect the case-sensitivity per file","enum":["auto","caseInsensitive","caseSensitive"],"markdownEnumDescriptions":["Detect case-sensitivity for import sorting.","%typescript.preferences.organizeImports.caseSensitivity.insensitive","Sort imports case-sensitively."],"default":"auto"},"typeOrder":{"type":"string","markdownDescription":"Specify how type-only named imports should be sorted.","enum":["auto","last","inline","first"],"default":"auto","markdownEnumDescriptions":["Detect where type-only named imports should be sorted.","Type only named imports are sorted to the end of the import list. E.g. `import { B, Z, type A, type Y } from 'module';`","Named imports are sorted by name only. E.g. `import { type A, B, type Y, Z } from 'module';`","Type only named imports are sorted to the beginning of the import list. E.g. `import { type A, type Y, B, Z } from 'module';`"]},"unicodeCollation":{"type":"string","markdownDescription":"Specify whether to sort imports using Unicode or Ordinal collation.","enum":["ordinal","unicode"],"markdownEnumDescriptions":["Sort imports using the numeric value of each code point.","Sort imports using the Unicode code collation."],"default":"ordinal"},"locale":{"type":"string","markdownDescription":"Requires `organizeImports.unicodeCollation: 'unicode'`. Overrides the locale used for collation. Specify `auto` to use the UI locale."},"numericCollation":{"type":"boolean","markdownDescription":"Requires `organizeImports.unicodeCollation: 'unicode'`. Sort numeric strings by integer value."},"accentCollation":{"type":"boolean","markdownDescription":"Requires `organizeImports.unicodeCollation: 'unicode'`. Compare characters with diacritical marks as unequal to base character."},"caseFirst":{"type":"string","markdownDescription":"Requires `organizeImports.unicodeCollation: 'unicode'`, and `organizeImports.caseSensitivity` is not `caseInsensitive`. Indicates whether upper-case will sort before lower-case.","enum":["default","upper","lower"],"markdownEnumDescriptions":["Default order given by `locale`.","Upper-case comes before lower-case. E.g. ` A, a, B, b`.","Lower-case comes before upper-case. E.g.` a, A, z, Z`."],"default":"default"}}}}},{"type":"object","title":"Formatting","order":23,"properties":{"javascript.format.enable":{"type":"boolean","default":true,"description":"Enable/disable default JavaScript formatter.","scope":"window"},"typescript.format.enable":{"type":"boolean","default":true,"description":"Enable/disable default TypeScript formatter.","scope":"window"},"javascript.format.insertSpaceAfterCommaDelimiter":{"type":"boolean","default":true,"description":"Defines space handling after a comma delimiter.","scope":"resource"},"typescript.format.insertSpaceAfterCommaDelimiter":{"type":"boolean","default":true,"description":"Defines space handling after a comma delimiter.","scope":"resource"},"javascript.format.insertSpaceAfterConstructor":{"type":"boolean","default":false,"description":"Defines space handling after the constructor keyword.","scope":"resource"},"typescript.format.insertSpaceAfterConstructor":{"type":"boolean","default":false,"description":"Defines space handling after the constructor keyword.","scope":"resource"},"javascript.format.insertSpaceAfterSemicolonInForStatements":{"type":"boolean","default":true,"description":"Defines space handling after a semicolon in a for statement.","scope":"resource"},"typescript.format.insertSpaceAfterSemicolonInForStatements":{"type":"boolean","default":true,"description":"Defines space handling after a semicolon in a for statement.","scope":"resource"},"javascript.format.insertSpaceBeforeAndAfterBinaryOperators":{"type":"boolean","default":true,"description":"Defines space handling after a binary operator.","scope":"resource"},"typescript.format.insertSpaceBeforeAndAfterBinaryOperators":{"type":"boolean","default":true,"description":"Defines space handling after a binary operator.","scope":"resource"},"javascript.format.insertSpaceAfterKeywordsInControlFlowStatements":{"type":"boolean","default":true,"description":"Defines space handling after keywords in a control flow statement.","scope":"resource"},"typescript.format.insertSpaceAfterKeywordsInControlFlowStatements":{"type":"boolean","default":true,"description":"Defines space handling after keywords in a control flow statement.","scope":"resource"},"javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions":{"type":"boolean","default":true,"description":"Defines space handling after function keyword for anonymous functions.","scope":"resource"},"typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions":{"type":"boolean","default":true,"description":"Defines space handling after function keyword for anonymous functions.","scope":"resource"},"javascript.format.insertSpaceBeforeFunctionParenthesis":{"type":"boolean","default":false,"description":"Defines space handling before function argument parentheses.","scope":"resource"},"typescript.format.insertSpaceBeforeFunctionParenthesis":{"type":"boolean","default":false,"description":"Defines space handling before function argument parentheses.","scope":"resource"},"javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis":{"type":"boolean","default":false,"description":"Defines space handling after opening and before closing non-empty parenthesis.","scope":"resource"},"typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis":{"type":"boolean","default":false,"description":"Defines space handling after opening and before closing non-empty parenthesis.","scope":"resource"},"javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets":{"type":"boolean","default":false,"description":"Defines space handling after opening and before closing non-empty brackets.","scope":"resource"},"typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets":{"type":"boolean","default":false,"description":"Defines space handling after opening and before closing non-empty brackets.","scope":"resource"},"javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces":{"type":"boolean","default":true,"description":"Defines space handling after opening and before closing non-empty braces.","scope":"resource"},"typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces":{"type":"boolean","default":true,"description":"Defines space handling after opening and before closing non-empty braces.","scope":"resource"},"javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces":{"type":"boolean","default":true,"description":"Defines space handling after opening and before closing empty braces.","scope":"resource"},"typescript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces":{"type":"boolean","default":true,"description":"Defines space handling after opening and before closing empty braces.","scope":"resource"},"javascript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces":{"type":"boolean","default":false,"description":"Defines space handling after opening and before closing template string braces.","scope":"resource"},"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces":{"type":"boolean","default":false,"description":"Defines space handling after opening and before closing template string braces.","scope":"resource"},"javascript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces":{"type":"boolean","default":false,"description":"Defines space handling after opening and before closing JSX expression braces.","scope":"resource"},"typescript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces":{"type":"boolean","default":false,"description":"Defines space handling after opening and before closing JSX expression braces.","scope":"resource"},"typescript.format.insertSpaceAfterTypeAssertion":{"type":"boolean","default":false,"description":"Defines space handling after type assertions in TypeScript.","scope":"resource"},"javascript.format.placeOpenBraceOnNewLineForFunctions":{"type":"boolean","default":false,"description":"Defines whether an open brace is put onto a new line for functions or not.","scope":"resource"},"typescript.format.placeOpenBraceOnNewLineForFunctions":{"type":"boolean","default":false,"description":"Defines whether an open brace is put onto a new line for functions or not.","scope":"resource"},"javascript.format.placeOpenBraceOnNewLineForControlBlocks":{"type":"boolean","default":false,"description":"Defines whether an open brace is put onto a new line for control blocks or not.","scope":"resource"},"typescript.format.placeOpenBraceOnNewLineForControlBlocks":{"type":"boolean","default":false,"description":"Defines whether an open brace is put onto a new line for control blocks or not.","scope":"resource"},"javascript.format.semicolons":{"type":"string","default":"ignore","description":"Defines handling of optional semicolons.","scope":"resource","enum":["ignore","insert","remove"],"enumDescriptions":["Don't insert or remove any semicolons.","Insert semicolons at statement ends.","Remove unnecessary semicolons."]},"typescript.format.semicolons":{"type":"string","default":"ignore","description":"Defines handling of optional semicolons.","scope":"resource","enum":["ignore","insert","remove"],"enumDescriptions":["Don't insert or remove any semicolons.","Insert semicolons at statement ends.","Remove unnecessary semicolons."]},"javascript.format.indentSwitchCase":{"type":"boolean","default":true,"description":"Indent case clauses in switch statements. Requires using TypeScript 5.1+ in the workspace.","scope":"resource"},"typescript.format.indentSwitchCase":{"type":"boolean","default":true,"description":"Indent case clauses in switch statements. Requires using TypeScript 5.1+ in the workspace.","scope":"resource"}}},{"type":"object","title":"Inlay Hints","order":24,"properties":{"typescript.inlayHints.parameterNames.enabled":{"type":"string","enum":["none","literals","all"],"enumDescriptions":["Disable parameter name hints.","Enable parameter name hints only for literal arguments.","Enable parameter name hints for literal and non-literal arguments."],"default":"none","markdownDescription":"Enable/disable inlay hints for parameter names:\n```typescript\n\nparseInt(/* str: */ '123', /* radix: */ 8)\n \n```","scope":"resource"},"typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName":{"type":"boolean","default":true,"markdownDescription":"Suppress parameter name hints on arguments whose text is identical to the parameter name.","scope":"resource"},"typescript.inlayHints.parameterTypes.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for implicit parameter types:\n```typescript\n\nel.addEventListener('click', e /* :MouseEvent */ => ...)\n \n```","scope":"resource"},"typescript.inlayHints.variableTypes.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for implicit variable types:\n```typescript\n\nconst foo /* :number */ = Date.now();\n \n```","scope":"resource"},"typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName":{"type":"boolean","default":true,"markdownDescription":"Suppress type hints on variables whose name is identical to the type name.","scope":"resource"},"typescript.inlayHints.propertyDeclarationTypes.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for implicit types on property declarations:\n```typescript\n\nclass Foo {\n\tprop /* :number */ = Date.now();\n}\n \n```","scope":"resource"},"typescript.inlayHints.functionLikeReturnTypes.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for implicit return types on function signatures:\n```typescript\n\nfunction foo() /* :number */ {\n\treturn Date.now();\n} \n \n```","scope":"resource"},"typescript.inlayHints.enumMemberValues.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for member values in enum declarations:\n```typescript\n\nenum MyValue {\n\tA /* = 0 */;\n\tB /* = 1 */;\n}\n \n```","scope":"resource"},"javascript.inlayHints.parameterNames.enabled":{"type":"string","enum":["none","literals","all"],"enumDescriptions":["Disable parameter name hints.","Enable parameter name hints only for literal arguments.","Enable parameter name hints for literal and non-literal arguments."],"default":"none","markdownDescription":"Enable/disable inlay hints for parameter names:\n```typescript\n\nparseInt(/* str: */ '123', /* radix: */ 8)\n \n```","scope":"resource"},"javascript.inlayHints.parameterNames.suppressWhenArgumentMatchesName":{"type":"boolean","default":true,"markdownDescription":"Suppress parameter name hints on arguments whose text is identical to the parameter name.","scope":"resource"},"javascript.inlayHints.parameterTypes.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for implicit parameter types:\n```typescript\n\nel.addEventListener('click', e /* :MouseEvent */ => ...)\n \n```","scope":"resource"},"javascript.inlayHints.variableTypes.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for implicit variable types:\n```typescript\n\nconst foo /* :number */ = Date.now();\n \n```","scope":"resource"},"javascript.inlayHints.variableTypes.suppressWhenTypeMatchesName":{"type":"boolean","default":true,"markdownDescription":"Suppress type hints on variables whose name is identical to the type name.","scope":"resource"},"javascript.inlayHints.propertyDeclarationTypes.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for implicit types on property declarations:\n```typescript\n\nclass Foo {\n\tprop /* :number */ = Date.now();\n}\n \n```","scope":"resource"},"javascript.inlayHints.functionLikeReturnTypes.enabled":{"type":"boolean","default":false,"markdownDescription":"Enable/disable inlay hints for implicit return types on function signatures:\n```typescript\n\nfunction foo() /* :number */ {\n\treturn Date.now();\n} \n \n```","scope":"resource"}}},{"type":"object","title":"TS Server","order":25,"properties":{"typescript.tsserver.nodePath":{"type":"string","description":"Run TS Server on a custom Node installation. This can be a path to a Node executable, or 'node' if you want VS Code to detect a Node installation.","scope":"window"},"typescript.npm":{"type":"string","markdownDescription":"Specifies the path to the npm executable used for [Automatic Type Acquisition](https://code.visualstudio.com/docs/nodejs/working-with-javascript#_typings-and-automatic-type-acquisition).","scope":"machine"},"typescript.check.npmIsInstalled":{"type":"boolean","default":true,"markdownDescription":"Check if npm is installed for [Automatic Type Acquisition](https://code.visualstudio.com/docs/nodejs/working-with-javascript#_typings-and-automatic-type-acquisition).","scope":"window"},"typescript.tsserver.web.projectWideIntellisense.enabled":{"type":"boolean","default":true,"description":"Enable/disable project-wide IntelliSense on web. Requires that VS Code is running in a trusted context.","scope":"window"},"typescript.tsserver.web.projectWideIntellisense.suppressSemanticErrors":{"type":"boolean","default":false,"description":"Suppresses semantic errors on web even when project wide IntelliSense is enabled. This is always on when project wide IntelliSense is not enabled or available. See `#typescript.tsserver.web.projectWideIntellisense.enabled#`","scope":"window"},"typescript.tsserver.web.typeAcquisition.enabled":{"type":"boolean","default":true,"description":"Enable/disable package acquisition on the web. This enables IntelliSense for imported packages. Requires `#typescript.tsserver.web.projectWideIntellisense.enabled#`. Currently not supported for Safari.","scope":"window"},"typescript.tsserver.useSyntaxServer":{"type":"string","scope":"window","description":"Controls if TypeScript launches a dedicated server to more quickly handle syntax related operations, such as computing code folding.","default":"auto","enum":["always","never","auto"],"enumDescriptions":["Use a lighter weight syntax server to handle all IntelliSense operations. This disables project-wide features including auto-imports, cross-file completions, and go to definition for symbols in other files. Only use this for very large projects where performance is critical.","Don't use a dedicated syntax server. Use a single server to handle all IntelliSense operations.","Spawn both a full server and a lighter weight server dedicated to syntax operations. The syntax server is used to speed up syntax operations and provide IntelliSense while projects are loading."]},"typescript.tsserver.maxTsServerMemory":{"type":"number","default":3072,"markdownDescription":"The maximum amount of memory (in MB) to allocate to the TypeScript server process. To use a memory limit greater than 4 GB, use `#typescript.tsserver.nodePath#` to run TS Server with a custom Node installation.","scope":"window"},"typescript.tsserver.experimental.enableProjectDiagnostics":{"type":"boolean","default":false,"description":"Enables project wide error reporting.","scope":"window","tags":["experimental"]},"typescript.tsserver.watchOptions":{"description":"Configure which watching strategies should be used to keep track of files and directories.","scope":"window","default":"vscode","oneOf":[{"type":"string","const":"vscode","description":"Use VS Code's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace."},{"type":"object","properties":{"watchFile":{"type":"string","description":"Strategy for how individual files are watched.","enum":["fixedChunkSizePolling","fixedPollingInterval","priorityPollingInterval","dynamicPriorityPolling","useFsEvents","useFsEventsOnParentDirectory"],"enumDescriptions":["Polls files in chunks at regular interval.","Check every file for changes several times a second at a fixed interval.","Check every file for changes several times a second, but use heuristics to check certain types of files less frequently than others.","Use a dynamic queue where less-frequently modified files will be checked less often.","Attempt to use the operating system/file system's native events for file changes.","Attempt to use the operating system/file system's native events to listen for changes on a file's containing directories. This can use fewer file watchers, but might be less accurate."],"default":"useFsEvents"},"watchDirectory":{"type":"string","description":"Strategy for how entire directory trees are watched under systems that lack recursive file-watching functionality.","enum":["fixedChunkSizePolling","fixedPollingInterval","dynamicPriorityPolling","useFsEvents"],"enumDescriptions":["Polls directories in chunks at regular interval.","Check every directory for changes several times a second at a fixed interval.","Use a dynamic queue where less-frequently modified directories will be checked less often.","Attempt to use the operating system/file system's native events for directory changes."],"default":"useFsEvents"},"fallbackPolling":{"type":"string","description":"When using file system events, this option specifies the polling strategy that gets used when the system runs out of native file watchers and/or doesn't support native file watchers.","enum":["fixedPollingInterval","priorityPollingInterval","dynamicPriorityPolling"],"enumDescriptions":["configuration.tsserver.watchOptions.fallbackPolling.fixedPollingInterval","configuration.tsserver.watchOptions.fallbackPolling.priorityPollingInterval","configuration.tsserver.watchOptions.fallbackPolling.dynamicPriorityPolling"]},"synchronousWatchDirectory":{"type":"boolean","description":"Disable deferred watching on directories. Deferred watching is useful when lots of file changes might occur at once (e.g. a change in node_modules from running npm install), but you might want to disable it with this flag for some less-common setups."}}}]},"typescript.tsserver.enableTracing":{"type":"boolean","default":false,"description":"Enables tracing TS server performance to a directory. These trace files can be used to diagnose TS Server performance issues. The log may contain file paths, source code, and other potentially sensitive information from your project.","scope":"window"},"typescript.tsserver.log":{"type":"string","enum":["off","terse","normal","verbose","requestTime"],"default":"off","description":"Enables logging of the TS server to a file. This log can be used to diagnose TS Server issues. The log may contain file paths, source code, and other potentially sensitive information from your project.","scope":"window"},"typescript.tsserver.pluginPaths":{"type":"array","items":{"type":"string","description":"Either an absolute or relative path. Relative path will be resolved against workspace folder(s)."},"default":[],"description":"Additional paths to discover TypeScript Language Service plugins.","scope":"machine"}}}],"commands":[{"command":"typescript.reloadProjects","title":"Reload Project","category":"TypeScript"},{"command":"javascript.reloadProjects","title":"Reload Project","category":"JavaScript"},{"command":"typescript.selectTypeScriptVersion","title":"Select TypeScript Version...","category":"TypeScript"},{"command":"typescript.goToProjectConfig","title":"Go to Project Configuration (tsconfig)","category":"TypeScript"},{"command":"javascript.goToProjectConfig","title":"Go to Project Configuration (jsconfig / tsconfig)","category":"JavaScript"},{"command":"typescript.openTsServerLog","title":"Open TS Server log","category":"TypeScript"},{"command":"typescript.restartTsServer","title":"Restart TS Server","category":"TypeScript"},{"command":"typescript.findAllFileReferences","title":"Find File References","category":"TypeScript"},{"command":"typescript.goToSourceDefinition","title":"Go to Source Definition","category":"TypeScript"},{"command":"typescript.sortImports","title":"Sort Imports","category":"TypeScript"},{"command":"javascript.sortImports","title":"Sort Imports","category":"JavaScript"},{"command":"typescript.removeUnusedImports","title":"Remove Unused Imports","category":"TypeScript"},{"command":"javascript.removeUnusedImports","title":"Remove Unused Imports","category":"JavaScript"},{"command":"typescript.experimental.enableTsgo","title":"Use TypeScript Go (Experimental)","category":"TypeScript","enablement":"!config.typescript.experimental.useTsgo && config.typescript-go.executablePath"},{"command":"typescript.experimental.disableTsgo","title":"Stop using TypeScript Go (Experimental)","category":"TypeScript","enablement":"config.typescript.experimental.useTsgo"}],"menus":{"commandPalette":[{"command":"typescript.reloadProjects","when":"editorLangId == typescript && typescript.isManagedFile"},{"command":"typescript.reloadProjects","when":"editorLangId == typescriptreact && typescript.isManagedFile"},{"command":"javascript.reloadProjects","when":"editorLangId == javascript && typescript.isManagedFile"},{"command":"javascript.reloadProjects","when":"editorLangId == javascriptreact && typescript.isManagedFile"},{"command":"typescript.goToProjectConfig","when":"editorLangId == typescript && typescript.isManagedFile"},{"command":"typescript.goToProjectConfig","when":"editorLangId == typescriptreact && typescript.isManagedFile"},{"command":"javascript.goToProjectConfig","when":"editorLangId == javascript && typescript.isManagedFile"},{"command":"javascript.goToProjectConfig","when":"editorLangId == javascriptreact && typescript.isManagedFile"},{"command":"typescript.selectTypeScriptVersion","when":"typescript.isManagedFile"},{"command":"typescript.openTsServerLog","when":"typescript.isManagedFile"},{"command":"typescript.restartTsServer","when":"typescript.isManagedFile"},{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && typescript.isManagedFile"},{"command":"typescript.goToSourceDefinition","when":"tsSupportsSourceDefinition && typescript.isManagedFile"},{"command":"typescript.sortImports","when":"supportedCodeAction =~ /(\\s|^)source\\.sortImports\\b/ && editorLangId =~ /^typescript(react)?$/"},{"command":"javascript.sortImports","when":"supportedCodeAction =~ /(\\s|^)source\\.sortImports\\b/ && editorLangId =~ /^javascript(react)?$/"},{"command":"typescript.removeUnusedImports","when":"supportedCodeAction =~ /(\\s|^)source\\.removeUnusedImports\\b/ && editorLangId =~ /^typescript(react)?$/"},{"command":"javascript.removeUnusedImports","when":"supportedCodeAction =~ /(\\s|^)source\\.removeUnusedImports\\b/ && editorLangId =~ /^javascript(react)?$/"}],"editor/context":[{"command":"typescript.goToSourceDefinition","when":"!config.typescript.experimental.useTsgo && tsSupportsSourceDefinition && (resourceLangId == typescript || resourceLangId == typescriptreact || resourceLangId == javascript || resourceLangId == javascriptreact)","group":"navigation@1.41"}],"explorer/context":[{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && resourceLangId == typescript","group":"4_search"},{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && resourceLangId == typescriptreact","group":"4_search"},{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && resourceLangId == javascript","group":"4_search"},{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && resourceLangId == javascriptreact","group":"4_search"}],"editor/title/context":[{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && resourceLangId == javascript"},{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && resourceLangId == javascriptreact"},{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && resourceLangId == typescript"},{"command":"typescript.findAllFileReferences","when":"tsSupportsFileReferences && resourceLangId == typescriptreact"}]},"breakpoints":[{"language":"typescript"},{"language":"typescriptreact"}],"taskDefinitions":[{"type":"typescript","required":["tsconfig"],"properties":{"tsconfig":{"type":"string","description":"The tsconfig file that defines the TS build."},"option":{"type":"string"}},"when":"shellExecutionSupported"}],"problemPatterns":[{"name":"tsc","regexp":"^([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$","file":1,"line":2,"column":3,"severity":4,"code":5,"message":6}],"problemMatchers":[{"name":"tsc","label":"TypeScript problems","owner":"typescript","source":"ts","applyTo":"closedDocuments","fileLocation":["relative","${cwd}"],"pattern":"$tsc"},{"name":"tsgo-watch","label":"TypeScript problems (watch mode)","owner":"typescript","source":"ts","applyTo":"closedDocuments","fileLocation":["relative","${cwd}"],"pattern":"$tsc","background":{"activeOnStart":true,"beginsPattern":{"regexp":"^build starting at .*$"},"endsPattern":{"regexp":"^build finished in .*$"}}},{"name":"tsc-watch","label":"TypeScript problems (watch mode)","owner":"typescript","source":"ts","applyTo":"closedDocuments","fileLocation":["relative","${cwd}"],"pattern":"$tsc","background":{"activeOnStart":true,"beginsPattern":{"regexp":"^\\s*(?:message TS6032:|\\[?\\D*.{1,2}[:.].{1,2}[:.].{1,2}\\D*(├\\D*\\d{1,2}\\D+┤)?(?:\\]| -)) (Starting compilation in watch mode|File change detected\\. Starting incremental compilation)\\.\\.\\."},"endsPattern":{"regexp":"^\\s*(?:message TS6042:|\\[?\\D*.{1,2}[:.].{1,2}[:.].{1,2}\\D*(├\\D*\\d{1,2}\\D+┤)?(?:\\]| -)) (?:Compilation complete\\.|Found \\d+ errors?\\.) Watching for file changes\\."}}}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/typescript-language-features","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.vb"},"manifest":{"name":"vb","displayName":"Visual Basic Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in Visual Basic files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"scripts":{"update-grammar":"node ../node_modules/vscode-grammar-updater/bin textmate/asp.vb.net.tmbundle Syntaxes/ASP%20VB.net.plist ./syntaxes/asp-vb-net.tmLanguage.json"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"vb","extensions":[".vb",".brs",".vbs",".bas",".vba"],"aliases":["Visual Basic","vb"],"configuration":"./language-configuration.json"}],"grammars":[{"language":"vb","scopeName":"source.asp.vb.net","path":"./syntaxes/asp-vb-net.tmLanguage.json"}],"snippets":[{"language":"vb","path":"./snippets/vb.code-snippets"}]},"repository":{"type":"git","url":"https://github.com/microsoft/vscode.git"}},"location":{"$mid":1,"path":"/c:/Users/Nico/AppData/Local/Programs/Microsoft VS Code/591199df40/resources/app/extensions/vb","scheme":"file"},"isBuiltin":true,"targetPlatform":"undefined","isValid":true,"validations":[],"preRelease":false},{"type":0,"identifier":{"id":"vscode.xml"},"manifest":{"name":"xml","displayName":"XML Language Basics","description":"Provides syntax highlighting and bracket matching in XML files.","version":"1.0.0","publisher":"vscode","license":"MIT","engines":{"vscode":"*"},"categories":["Programming Languages"],"contributes":{"languages":[{"id":"xml","extensions":[".xml",".xsd",".ascx",".atom",".axml",".axaml",".bpmn",".cpt",".csl",".csproj",".csproj.user",".dita",".ditamap",".dtd",".ent",".mod",".dtml",".fsproj",".fxml",".iml",".isml",".jmx",".launch",".menu",".mxml",".nuspec",".opml",".owl",".proj",".props",".pt",".publishsettings",".pubxml",".pubxml.user",".rbxlx",".rbxmx",".rdf",".rng",".rss",".shproj",".slnx",".storyboard",".svg",".targets",".tld",".tmx",".vbproj",".vbproj.user",".vcxproj",".vcxproj.filters",".wsdl",".wxi",".wxl",".wxs",".xaml",".xbl",".xib",".xlf",".xliff",".xpdl",".xul",".xoml"],"firstLine":"(\\<\\?xml.*)|(\\ Client <-> Hooks <-> UI) + +### No-assumptions rule (required) +Before making structural changes, first scan the repo and identify: +- where `app/`, `components/`, `features/`, `hooks/`, `lib/` live +- existing API routes and helpers +- patterns already in use +Do not invent files/endpoints/conventions. If something is missing, add it **minimally** and **consistently**. + +### Single mechanism rule (required) +For any cross-component state propagation concern, keep **one** canonical mechanism only: +- Context **OR** custom events **OR** cache invalidation +Do not keep old and new mechanisms in parallel. Remove superseded utilities/imports/files in the same PR. + +### Layering (hard boundaries) +For every domain (auth, groups, entries, receipts, etc.) follow this flow: + +1) **API Route Handlers** - `app/api/.../route.ts` +- Thin: parse/validate input, call a server service, return JSON. +- No direct DB queries in route files unless there is no existing server service. + +2) **Server Services (DB + authorization)** - `lib/server/*` +- Own all DB access and authorization helpers. +- Server-only modules must include: `import "server-only";` +- Prefer small domain modules: `lib/server/auth.ts`, `lib/server/groups.ts`, `lib/server/entries.ts`, `lib/server/receipts.ts`, `lib/server/session.ts`. + +3) **Client API Wrappers** - `lib/client/*` +- Typed fetch helpers only (no React state). +- Centralize fetch + error normalization. +- Always send credentials (cookies) and never trust client-side RBAC. + +4) **Hooks (UI-facing API layer)** - `hooks/use-*.ts` +- Hooks are the primary interface for components/pages to call APIs. +- Components should not call `fetch()` directly unless there is a strong reason. + +### API conventions +- Prefer consistent JSON error shape: + - `{ error: { code: string, message: string }, request_id?: string }` +- Validate inputs at the route boundary (shape/type), authorize in server services. +- Mirror existing REST style used in the project. + +### Next.js route params checklist (required) +For `app/api/**/[param]/route.ts`: +- Treat `context.params` as **async** and `await` it before reading properties. + - Example: `const { id } = await context.params;` + +### Frontend structure preference +- Prefer domain-first structure: `features//...` + `shared/...`. +- Use `components/*` only for compatibility shims during migrations (remove them after imports are migrated). + +### Maintainability thresholds (refactor triggers) +- Component files > **400 lines** should be split into container/presentational parts. +- Hook files > **150 lines** should extract helper functions/services. +- Functions with more than **3 nested branches** should be extracted. + +--- + +## 6) Decisions / constraints (Group Settings) +- Add `GROUP_OWNER` role to group roles; migrate existing groups so the first admin becomes owner. +- Join policy default is `NOT_ACCEPTING`. Policies: `NOT_ACCEPTING`, `AUTO_ACCEPT`, `APPROVAL_REQUIRED`. +- Both owner and admins can approve join requests and manage invite links. +- Invite links: + - TTL limited to 1-7 days. + - Settings are immutable after creation (policy, single-use, etc.). + - Single-use does not override approval-required. + - Expired links are retained and can be revived. + - Single-use links are deleted after successful use. + - Revive resets `used_at` and `revoked_at`, refreshes `expires_at`, and creates a new audit event. +- No cron/worker jobs for now (auto ownership transfer and invite rotation are paused). +- Group role icons must be consistent: owner, admin, member. + +--- + +## 7) Do first (vertical slice) +1) DB migrate command + schema +2) Register/Login/Logout (custom sessions) +3) Protected dashboard page +4) Group create/join + group switcher (approval-based joins + optional join disable) +5) Entries CRUD (no receipt bytes in list) +6) Receipt upload/download endpoints +7) Settings + Reports + +--- + +## 8) Definition of done +- Works via `docker-compose.dev.yml` with external DB +- Migrations applied via `npm run db:migrate` +- Tests + lint pass +- RBAC enforced server-side +- No large files +- No TypeScript warnings or lint errors in touched files +- No new cron/worker dependencies unless explicitly approved +- No orphaned utilities/hooks/contexts after refactors +- No duplicate mechanisms for the same state flow +- Text encoding remains clean in user-facing strings/docs + +--- + +## 9) Desktop + mobile UX checklist (required) +- Touch: long-press affordance for item-level actions when no visible button. +- Mouse: hover affordance on interactive rows/cards. +- Tap targets remain >= 40px on mobile. +- Modal overlays must close on outside click/tap. +- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure). +- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception. +- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency. +- Add Playwright UI tests for new UI features and critical flows. + +--- + +## 10) Tests (required) +- Add/update tests for API behavior changes (auth, groups, entries, receipts). +- Include negative cases where applicable: + - unauthorized + - not-a-member + - invalid input + +--- + +## 11) Agent Response Legend (required) +Use emoji/icons in agent progress and final responses so status is obvious at a glance. + +Legend: +- `🔄` in progress +- `✅` completed +- `🧪` test/lint/verification result +- `📄` documentation update +- `🗄️` database or migration change +- `🚀` deploy/release step +- `⚠️` risk, blocker, or manual operator action needed +- `❌` failed command or unsuccessful attempt +- `ℹ️` informational context +- `🧭` recommendation or next-step option + +Usage rules: +- Include at least one status icon in each substantive agent response. +- Use one icon per bullet/line; avoid icon spam. +- Keep icon meaning consistent with this legend. + +--- + +## 12) Commit Discipline (required) +- Treat committing as a first-class part of the workflow: create frequent, verified checkpoint commits for completed work instead of accumulating large uncommitted changes. +- Commit in small, logical slices (no broad mixed-purpose commits). +- Each commit must: + - follow Conventional Commits style (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`) + - include only related files for that slice + - exclude secrets, credentials, and generated noise +- Run verification before commit when applicable (lint/tests/build or targeted checks for touched areas). +- Prefer frequent checkpoint commits during agentic work rather than one large end-state commit. +- Before switching tasks or stopping after a completed change, check git status and either commit the finished slice or clearly document why it remains uncommitted. +- If a rule or contract changes, commit docs first (or in the same atomic slice as enforcing code). diff --git a/README.md b/README.md index 35f86b3..8b9ac01 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A full-stack web application for managing grocery shopping lists with role-based access control, image support, and intelligent item classification. +> Current maintainer notes: `PROJECT_INSTRUCTIONS.md` is the source of truth for project constraints. For current setup, run, and verification commands, start with `docs/DEVELOPMENT.md` and `docs/PROJECT_MAP.md`; some older sections below are historical and should be checked against current code before changing behavior. + ![License](https://img.shields.io/badge/license-MIT-blue.svg) ![Node](https://img.shields.io/badge/node-20.x-green.svg) ![React](https://img.shields.io/badge/react-19.2.0-blue.svg) @@ -324,8 +326,8 @@ router.post("/add", 1. **Clone the repository** ```bash - git clone https://github.com/your-org/costco-grocery-list.git - cd costco-grocery-list + git clone https://git.nicosaya.com/nalalangan/grocery-app.git + cd grocery-app ``` 2. **Configure environment variables** @@ -421,7 +423,7 @@ Authorization: Bearer ## 📁 Project Structure ``` -costco-grocery-list/ +grocery-app/ ├── .gitea/ │ └── workflows/ │ └── deploy.yml # CI/CD pipeline configuration @@ -747,7 +749,7 @@ This project is licensed under the MIT License - see the LICENSE file for detail ## 👤 Author **Nico Saya** -- Repository: [git.nicosaya.com/nalalangan/costco-grocery-list](https://git.nicosaya.com/nalalangan/costco-grocery-list) +- Repository: [git.nicosaya.com/nalalangan/grocery-app](https://git.nicosaya.com/nalalangan/grocery-app) --- diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..acb7711 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,11 @@ +DATABASE_URL=postgres://username:password@db-host:5432/database_name +DB_USER= +DB_PASS= +DB_HOST= +DB_PORT=5432 +DB_NAME= +PORT=5000 +JWT_SECRET=change-me +ALLOWED_ORIGINS=http://localhost:3000 +SESSION_COOKIE_NAME=sid +SESSION_TTL_DAYS=30 diff --git a/backend/Dockerfile b/backend/Dockerfile index 79c6597..eb410e8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,12 +1,14 @@ -FROM node:20-alpine - -WORKDIR /app - -COPY package*.json ./ -RUN npm install +FROM node:20-alpine + +WORKDIR /app + +RUN apk add --no-cache postgresql-client + +COPY package*.json ./ +RUN npm install COPY . . EXPOSE 5000 -CMD ["npm", "run", "dev"] \ No newline at end of file +CMD ["npm", "run", "dev"] diff --git a/backend/app.js b/backend/app.js index 60253cc..c6a97f1 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,46 +1,81 @@ -const express = require("express"); -const cors = require("cors"); -const User = require("./models/user.model"); - -const app = express(); -app.use(express.json()); - -const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim()); -console.log("Allowed Origins:", allowedOrigins); -app.use( - cors({ - origin: function (origin, callback) { - if (!origin) return callback(null, true); - if (allowedOrigins.includes(origin)) return callback(null, true); - if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); - if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); - callback(new Error("Not allowed by CORS")); - }, - methods: ["GET", "POST", "PUT", "DELETE"], - }) -); - -app.get('/', async (req, res) => { - resText = `Grocery List API is running.\n` + - `Roles available: ${Object.values(User.ROLES).join(', ')}` - - res.status(200).type("text/plain").send(resText); -}); - - -const authRoutes = require("./routes/auth.routes"); -app.use("/auth", authRoutes); - -const listRoutes = require("./routes/list.routes"); -app.use("/list", listRoutes); - -const adminRoutes = require("./routes/admin.routes"); -app.use("/admin", adminRoutes); - -const usersRoutes = require("./routes/users.routes"); -app.use("/users", usersRoutes); - -const configRoutes = require("./routes/config.routes"); -app.use("/config", configRoutes); - -module.exports = app; \ No newline at end of file +const express = require("express"); +const cors = require("cors"); +const path = require("path"); +const User = require("./models/user.model"); +const requestIdMiddleware = require("./middleware/request-id"); +const { sendError } = require("./utils/http"); + +const app = express(); +app.use(requestIdMiddleware); +app.use(express.json()); + +// Expose manual API test pages in non-production environments only. +if (process.env.NODE_ENV !== "production") { + app.use("/test", express.static(path.join(__dirname, "public"))); +} + +const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +app.use( + cors({ + origin: function (origin, callback) { + if (!origin) return callback(null, true); + if (allowedOrigins.includes(origin)) return callback(null, true); + if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); + if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); + console.error(`CORS blocked origin: ${origin}`); + callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); + }, + methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], + credentials: true, + exposedHeaders: ["X-Request-Id"], + }) +); + +app.get('/', async (req, res) => { + res.status(200).json({ + message: "Grocery List API is running.", + roles: Object.values(User.ROLES), + }); +}); + + +const authRoutes = require("./routes/auth.routes"); +app.use("/auth", authRoutes); + +const listRoutes = require("./routes/list.routes"); +app.use("/list", listRoutes); + +const adminRoutes = require("./routes/admin.routes"); +app.use("/admin", adminRoutes); + +const usersRoutes = require("./routes/users.routes"); +app.use("/users", usersRoutes); + +const configRoutes = require("./routes/config.routes"); +app.use("/config", configRoutes); + +const householdsRoutes = require("./routes/households.routes"); +app.use("/households", householdsRoutes); + +const storesRoutes = require("./routes/stores.routes"); +app.use("/stores", storesRoutes); + +const groupInvitesRoutes = require("./routes/group-invites.routes"); +app.use("/api", groupInvitesRoutes); + +app.use((err, req, res, next) => { + if (res.headersSent) { + return next(err); + } + + const statusCode = err.status || err.statusCode || 500; + const message = + statusCode >= 500 ? "Internal server error" : err.message || "Request failed"; + + return sendError(res, statusCode, message); +}); + +module.exports = app; diff --git a/backend/build.js b/backend/build.js new file mode 100644 index 0000000..37e91aa --- /dev/null +++ b/backend/build.js @@ -0,0 +1,62 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const rootDir = __dirname; +const distDir = path.join(rootDir, "dist"); + +const directoriesToCopy = [ + "config", + "constants", + "controllers", + "db", + "middleware", + "models", + "routes", + "services", + "utils", + "public", +]; + +const filesToCopy = ["app.js", "server.js", "package.json", "package-lock.json"]; + +function copyFile(sourcePath, targetPath) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.copyFileSync(sourcePath, targetPath); +} + +function copyDirectory(sourceDir, targetDir) { + if (!fs.existsSync(sourceDir)) { + return; + } + + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + copyDirectory(sourcePath, targetPath); + continue; + } + + if (entry.isFile()) { + copyFile(sourcePath, targetPath); + } + } +} + +fs.mkdirSync(distDir, { recursive: true }); + +for (const directory of directoriesToCopy) { + copyDirectory(path.join(rootDir, directory), path.join(distDir, directory)); +} + +for (const file of filesToCopy) { + const sourcePath = path.join(rootDir, file); + if (fs.existsSync(sourcePath)) { + copyFile(sourcePath, path.join(distDir, file)); + } +} + +console.log(`Backend build copied runtime files to ${path.relative(rootDir, distDir)}`); diff --git a/backend/controllers/auth.controller.js b/backend/controllers/auth.controller.js index 40ba145..0226ef6 100644 --- a/backend/controllers/auth.controller.js +++ b/backend/controllers/auth.controller.js @@ -1,44 +1,101 @@ -const bcrypt = require("bcryptjs"); -const jwt = require("jsonwebtoken"); -const User = require("../models/user.model"); - -exports.register = async (req, res) => { - let { username, password, name } = req.body; - username = username.toLowerCase(); - console.log(`🆕 Registration attempt for ${name} => username:${username}, password:${password}`); - - try { - const hash = await bcrypt.hash(password, 10); - const user = await User.createUser(username, hash, name); - console.log(`✅ User registered: ${username}`); - - res.json({ message: "User registered", user }); - } catch (err) { - res.status(400).json({ message: "Registration failed", error: err }); - } -}; - -exports.login = async (req, res) => { - let { username, password } = req.body; - - username = username.toLowerCase(); - const user = await User.findByUsername(username); - if (!user) { - console.log(`⚠️ Login attempt -> No user found: ${username}`); - return res.status(401).json({ message: "User not found" }); - } - - const valid = await bcrypt.compare(password, user.password); - if (!valid) { - console.log(`⛔ Login attempt for user ${username} with password ${password}`); - return res.status(401).json({ message: "Invalid credentials" }); - } - - const token = jwt.sign( - { id: user.id, role: user.role }, - process.env.JWT_SECRET, - { expiresIn: "1 year" } - ); - - res.json({ token, username, role: user.role }); -}; +const bcrypt = require("bcryptjs"); +const jwt = require("jsonwebtoken"); +const User = require("../models/user.model"); +const { sendError } = require("../utils/http"); +const Session = require("../models/session.model"); +const { parseCookieHeader } = require("../utils/cookies"); +const { setSessionCookie, clearSessionCookie, cookieName } = require("../utils/session-cookie"); +const { logError } = require("../utils/logger"); + +exports.register = async (req, res) => { + let { username, password, name } = req.body; + + if ( + !username || + !password || + !name || + typeof username !== "string" || + typeof password !== "string" || + typeof name !== "string" + ) { + return sendError(res, 400, "Username, password, and name are required"); + } + + username = username.toLowerCase(); + if (password.length < 8) { + return sendError(res, 400, "Password must be at least 8 characters"); + } + + try { + const hash = await bcrypt.hash(password, 10); + const user = await User.createUser(username, hash, name); + + res.json({ message: "User registered", user }); + } catch (err) { + logError(req, "auth.register", err); + sendError(res, 400, "Registration failed"); + } +}; + +exports.login = async (req, res) => { + let { username, password } = req.body; + + if ( + !username || + !password || + typeof username !== "string" || + typeof password !== "string" + ) { + return sendError(res, 400, "Username and password are required"); + } + + username = username.toLowerCase(); + const user = await User.findByUsername(username); + if (!user) { + return sendError(res, 401, "Invalid credentials"); + } + + const valid = await bcrypt.compare(password, user.password); + if (!valid) { + return sendError(res, 401, "Invalid credentials"); + } + + const jwtSecret = process.env.JWT_SECRET; + if (!jwtSecret) { + logError(req, "auth.login.jwtSecretMissing", new Error("JWT_SECRET is not configured")); + return sendError(res, 500, "Authentication is unavailable"); + } + + const token = jwt.sign( + { id: user.id, role: user.role }, + jwtSecret, + { expiresIn: "1 year" } + ); + + try { + const session = await Session.createSession(user.id, req.headers["user-agent"] || null); + setSessionCookie(res, session.id); + } catch (err) { + logError(req, "auth.login.createSession", err); + return sendError(res, 500, "Failed to create session"); + } + + res.json({ token, userId: user.id, username, role: user.role }); +}; + +exports.logout = async (req, res) => { + try { + const cookies = parseCookieHeader(req.headers.cookie); + const sid = cookies[cookieName()]; + + if (sid) { + await Session.deleteSession(sid); + } + + clearSessionCookie(res); + res.json({ message: "Logged out" }); + } catch (err) { + logError(req, "auth.logout", err); + sendError(res, 500, "Failed to logout"); + } +}; diff --git a/backend/controllers/available-items.controller.js b/backend/controllers/available-items.controller.js new file mode 100644 index 0000000..74617a1 --- /dev/null +++ b/backend/controllers/available-items.controller.js @@ -0,0 +1,319 @@ +const AvailableItems = require("../models/available-item.model"); +const List = require("../models/list.model.v2"); +const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); +const { sendError } = require("../utils/http"); +const { logError } = require("../utils/logger"); + +const LEGACY_ITEM_TYPE_MAP = { + beverages: "beverage", + snacks: "snack", +}; + +function parseBoolean(value) { + return value === true || value === "true" || value === "1"; +} + +function isCatalogTableMissing(error) { + return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || ""); +} + +function parseClassificationInput(value) { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return null; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (trimmed === "null") { + return null; + } + + if (trimmed.startsWith("{")) { + try { + return JSON.parse(trimmed); + } catch (error) { + return Symbol.for("invalid-classification-json"); + } + } + + return trimmed; + } + + return value; +} + +function normalizeClassificationPayload(classification) { + if (typeof classification === "string") { + const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification; + return { + item_type: normalizedItemType, + item_group: null, + zone: null, + }; + } + + if (!classification || typeof classification !== "object" || Array.isArray(classification)) { + return null; + } + + const item_type = + typeof classification.item_type === "string" && classification.item_type.trim() !== "" + ? classification.item_type.trim() + : null; + const item_group = + typeof classification.item_group === "string" && classification.item_group.trim() !== "" + ? classification.item_group.trim() + : null; + const zone = + typeof classification.zone === "string" && classification.zone.trim() !== "" + ? classification.zone.trim() + : null; + + if (!item_type && !item_group && !zone) { + return null; + } + + return { item_type, item_group, zone }; +} + +function validateClassification(res, classification) { + if (!classification) { + return false; + } + + const { item_type, item_group, zone } = classification; + + if (item_type && !isValidItemType(item_type)) { + sendError(res, 400, "Invalid item_type"); + return true; + } + + if (item_group && !item_type) { + sendError(res, 400, "Item type is required when item group is provided"); + return true; + } + + if (item_group && !isValidItemGroup(item_type, item_group)) { + sendError(res, 400, "Invalid item_group for selected item_type"); + return true; + } + + if (zone && !isValidZone(zone)) { + sendError(res, 400, "Invalid zone"); + return true; + } + + return false; +} + +function parseItemId(value) { + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +exports.getAvailableItems = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || ""); + res.json({ items, catalog_ready: true }); + } catch (error) { + if (isCatalogTableMissing(error)) { + return res.json({ + items: [], + catalog_ready: false, + message: "Store item management is unavailable until the latest database migration is applied.", + }); + } + logError(req, "availableItems.getAvailableItems", error); + sendError(res, 500, "Failed to load available items"); + } +}; + +exports.createAvailableItem = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name } = req.body; + + if (!item_name || item_name.trim() === "") { + return sendError(res, 400, "Item name is required"); + } + + const parsedClassification = parseClassificationInput(req.body.classification); + if (parsedClassification === Symbol.for("invalid-classification-json")) { + return sendError(res, 400, "Classification payload must be valid JSON"); + } + + const normalizedClassification = normalizeClassificationPayload(parsedClassification); + if (validateClassification(res, normalizedClassification)) { + return; + } + + const imageBuffer = req.processedImage?.buffer || null; + const mimeType = req.processedImage?.mimeType || null; + + const item = await AvailableItems.createAvailableItem( + householdId, + storeId, + item_name, + imageBuffer, + mimeType + ); + + if (normalizedClassification) { + await List.upsertClassification(householdId, storeId, item.item_id, { + ...normalizedClassification, + confidence: 1.0, + source: "user", + }); + } + + const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id); + + res.status(201).json({ + message: "Available item added", + item: refreshedItem, + }); + } catch (error) { + if (isCatalogTableMissing(error)) { + return sendError( + res, + 503, + "Store item management is unavailable until the latest database migration is applied" + ); + } + logError(req, "availableItems.createAvailableItem", error); + if (error.code === "23505") { + return sendError(res, 400, "Available item already exists for this store"); + } + sendError(res, 500, "Failed to add available item"); + } +}; + +exports.updateAvailableItem = async (req, res) => { + try { + const { householdId, storeId, itemId: rawItemId } = req.params; + const itemId = parseItemId(rawItemId); + + if (!itemId) { + return sendError(res, 400, "Item ID must be a positive integer"); + } + + const hasClassificationField = Object.prototype.hasOwnProperty.call(req.body, "classification"); + const parsedClassification = parseClassificationInput(req.body.classification); + + if (parsedClassification === Symbol.for("invalid-classification-json")) { + return sendError(res, 400, "Classification payload must be valid JSON"); + } + + const normalizedClassification = normalizeClassificationPayload(parsedClassification); + if (normalizedClassification && validateClassification(res, normalizedClassification)) { + return; + } + + const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, { + itemName: req.body.item_name, + imageBuffer: req.processedImage?.buffer || null, + mimeType: req.processedImage?.mimeType || null, + removeImage: parseBoolean(req.body.remove_image), + }); + + if (!updatedItem) { + return sendError(res, 404, "Available item not found"); + } + + if (hasClassificationField) { + if (normalizedClassification) { + await List.upsertClassification(householdId, storeId, updatedItem.item_id, { + ...normalizedClassification, + confidence: 1.0, + source: "user", + }); + } else { + await List.deleteClassification(householdId, storeId, updatedItem.item_id); + } + } + + const refreshedItem = await AvailableItems.getAvailableItemById( + householdId, + storeId, + updatedItem.item_id + ); + + res.json({ + message: "Available item updated", + item: refreshedItem, + }); + } catch (error) { + if (isCatalogTableMissing(error)) { + return sendError( + res, + 503, + "Store item management is unavailable until the latest database migration is applied" + ); + } + logError(req, "availableItems.updateAvailableItem", error); + if (error.code === "23505") { + return sendError(res, 400, "Available item already exists for this store"); + } + sendError(res, 500, "Failed to update available item"); + } +}; + +exports.deleteAvailableItem = async (req, res) => { + try { + const { householdId, storeId, itemId: rawItemId } = req.params; + const itemId = parseItemId(rawItemId); + + if (!itemId) { + return sendError(res, 400, "Item ID must be a positive integer"); + } + + const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId); + + if (!deleted) { + return sendError(res, 404, "Store item not found"); + } + + res.json({ message: "Store item deleted" }); + } catch (error) { + if (isCatalogTableMissing(error)) { + return sendError( + res, + 503, + "Store item management is unavailable until the latest database migration is applied" + ); + } + logError(req, "availableItems.deleteAvailableItem", error); + sendError(res, 500, "Failed to delete store item"); + } +}; + +exports.importCurrentItems = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId); + + res.json({ + message: importedCount > 0 ? "Imported current list items" : "No current list items to import", + imported_count: importedCount, + }); + } catch (error) { + if (isCatalogTableMissing(error)) { + return sendError( + res, + 503, + "Store item management is unavailable until the latest database migration is applied" + ); + } + logError(req, "availableItems.importCurrentItems", error); + sendError(res, 500, "Failed to import current list items"); + } +}; diff --git a/backend/controllers/group-invites.controller.js b/backend/controllers/group-invites.controller.js new file mode 100644 index 0000000..7807b8a --- /dev/null +++ b/backend/controllers/group-invites.controller.js @@ -0,0 +1,252 @@ +const invitesService = require("../services/group-invites.service"); +const { sendError } = require("../utils/http"); +const { logError } = require("../utils/logger"); +const { inviteCodeLast4 } = require("../utils/redaction"); + +function getClientIp(req) { + const forwardedFor = req.headers["x-forwarded-for"]; + if (typeof forwardedFor === "string" && forwardedFor.trim()) { + return forwardedFor.split(",")[0].trim(); + } + return req.ip || req.socket?.remoteAddress || null; +} + +function parseRequestedGroupId(req) { + const headerGroupId = req.headers["x-group-id"] || req.headers["x-household-id"]; + if (headerGroupId) { + const raw = Array.isArray(headerGroupId) ? headerGroupId[0] : headerGroupId; + return raw; + } + if (req.query?.groupId !== undefined) { + return req.query.groupId; + } + if (req.body?.groupId !== undefined) { + return req.body.groupId; + } + return undefined; +} + +function clampTtlDays(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed)) return 1; + return Math.max(1, Math.min(7, parsed)); +} + +function mapServiceError(req, res, error, context, extraLog = {}) { + if (error instanceof invitesService.InviteServiceError) { + return sendError(res, error.statusCode, error.message, error.code); + } + logError(req, context, error, extraLog); + return sendError(res, 500, "Failed to process invite request"); +} + +exports.listInviteLinks = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + const links = await invitesService.listInviteLinks(req.user.id, groupId); + res.json({ links }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.listInviteLinks"); + } +}; + +exports.createInviteLink = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + const ttlDays = clampTtlDays(req.body?.ttlDays); + const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000); + const link = await invitesService.createInviteLink( + req.user.id, + groupId, + req.body?.policy, + Boolean(req.body?.singleUse), + expiresAt, + req.request_id, + getClientIp(req), + req.headers["user-agent"] || null + ); + res.status(201).json({ link }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.createInviteLink"); + } +}; + +exports.listPendingJoinRequests = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + const requests = await invitesService.listPendingJoinRequests(req.user.id, groupId); + res.json({ requests }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.listPendingJoinRequests"); + } +}; + +exports.revokeInviteLink = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + await invitesService.revokeInviteLink( + req.user.id, + groupId, + req.body?.linkId, + req.request_id, + getClientIp(req), + req.headers["user-agent"] || null + ); + res.json({ ok: true }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.revokeInviteLink"); + } +}; + +exports.reviveInviteLink = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + const ttlDays = clampTtlDays(req.body?.ttlDays); + const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000); + await invitesService.reviveInviteLink( + req.user.id, + groupId, + req.body?.linkId, + expiresAt, + req.request_id, + getClientIp(req), + req.headers["user-agent"] || null + ); + res.json({ ok: true }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.reviveInviteLink"); + } +}; + +exports.deleteInviteLink = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + await invitesService.deleteInviteLink( + req.user.id, + groupId, + req.body?.linkId, + req.request_id, + getClientIp(req), + req.headers["user-agent"] || null + ); + res.json({ ok: true }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.deleteInviteLink"); + } +}; + +exports.getJoinPolicy = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + const joinPolicy = await invitesService.getGroupJoinPolicy(req.user.id, groupId); + res.json({ joinPolicy }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.getJoinPolicy"); + } +}; + +exports.setJoinPolicy = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + await invitesService.setGroupJoinPolicy( + req.user.id, + groupId, + req.body?.joinPolicy, + req.request_id, + getClientIp(req), + req.headers["user-agent"] || null + ); + res.json({ ok: true }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.setJoinPolicy"); + } +}; + +exports.decideJoinRequest = async (req, res) => { + try { + const requestedGroupId = parseRequestedGroupId(req); + const groupId = await invitesService.resolveManagedGroupId( + req.user.id, + requestedGroupId + ); + const decision = await invitesService.decideJoinRequest( + req.user.id, + groupId, + req.body?.requestId, + req.body?.decision, + req.request_id, + getClientIp(req), + req.headers["user-agent"] || null + ); + res.json({ request: decision }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.decideJoinRequest"); + } +}; + +exports.getInviteLinkSummary = async (req, res) => { + const token = req.params.token; + const inviteLast4 = inviteCodeLast4(token); + try { + const link = await invitesService.getInviteLinkSummaryByToken( + token, + req.user?.id || null + ); + res.json({ link }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.getInviteLinkSummary", { + invite_last4: inviteLast4, + }); + } +}; + +exports.acceptInviteLink = async (req, res) => { + const token = req.params.token; + const inviteLast4 = inviteCodeLast4(token); + try { + const result = await invitesService.acceptInviteLink( + req.user.id, + token, + req.request_id, + getClientIp(req), + req.headers["user-agent"] || null + ); + res.json({ result }); + } catch (error) { + return mapServiceError(req, res, error, "groupInvites.acceptInviteLink", { + invite_last4: inviteLast4, + }); + } +}; diff --git a/backend/controllers/households.controller.js b/backend/controllers/households.controller.js new file mode 100644 index 0000000..49f0e53 --- /dev/null +++ b/backend/controllers/households.controller.js @@ -0,0 +1,239 @@ +const householdModel = require("../models/household.model"); +const { sendError } = require("../utils/http"); +const { inviteCodeLast4 } = require("../utils/redaction"); +const { logError } = require("../utils/logger"); + +// Get all households user belongs to +exports.getUserHouseholds = async (req, res) => { + try { + const households = await householdModel.getUserHouseholds(req.user.id); + res.json(households); + } catch (error) { + logError(req, "households.getUserHouseholds", error); + sendError(res, 500, "Failed to fetch households"); + } +}; + +// Get household details +exports.getHousehold = async (req, res) => { + try { + const household = await householdModel.getHouseholdById( + req.params.householdId, + req.user.id + ); + + if (!household) { + return sendError(res, 404, "Household not found"); + } + + res.json(household); + } catch (error) { + logError(req, "households.getHousehold", error); + sendError(res, 500, "Failed to fetch household"); + } +}; + +// Create new household +exports.createHousehold = async (req, res) => { + try { + const { name } = req.body; + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Household name is required"); + } + + if (name.length > 100) { + return sendError(res, 400, "Household name must be 100 characters or less"); + } + + const household = await householdModel.createHousehold( + name.trim(), + req.user.id + ); + + res.status(201).json({ + message: "Household created successfully", + household + }); + } catch (error) { + logError(req, "households.createHousehold", error); + sendError(res, 500, "Failed to create household"); + } +}; + +// Update household +exports.updateHousehold = async (req, res) => { + try { + const { name } = req.body; + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Household name is required"); + } + + if (name.length > 100) { + return sendError(res, 400, "Household name must be 100 characters or less"); + } + + const household = await householdModel.updateHousehold( + req.params.householdId, + { name: name.trim() } + ); + + res.json({ + message: "Household updated successfully", + household + }); + } catch (error) { + logError(req, "households.updateHousehold", error); + sendError(res, 500, "Failed to update household"); + } +}; + +// Delete household +exports.deleteHousehold = async (req, res) => { + try { + await householdModel.deleteHousehold(req.params.householdId); + res.json({ message: "Household deleted successfully" }); + } catch (error) { + logError(req, "households.deleteHousehold", error); + sendError(res, 500, "Failed to delete household"); + } +}; + +// Refresh invite code +exports.refreshInviteCode = async (req, res) => { + try { + const household = await householdModel.refreshInviteCode(req.params.householdId); + res.json({ + message: "Invite code refreshed successfully", + household + }); + } catch (error) { + logError(req, "households.refreshInviteCode", error, { + invite_last4: inviteCodeLast4(req.body?.inviteCode), + }); + sendError(res, 500, "Failed to refresh invite code"); + } +}; + +// Join household via invite code +exports.joinHousehold = async (req, res) => { + const inviteLast4 = inviteCodeLast4(req.params.inviteCode); + try { + const { inviteCode } = req.params; + if (!inviteCode) return sendError(res, 400, "Invite code is required"); + + const result = await householdModel.joinHousehold( + inviteCode.toUpperCase(), + req.user.id + ); + + if (!result) return sendError(res, 404, "Invalid or expired invite code"); + + + if (result.alreadyMember) { + return res.status(200).json({ + message: "You are already a member of this household", + household: { id: result.id, name: result.name } + }); + } + + res.status(200).json({ + message: `Successfully joined ${result.name}`, + household: { id: result.id, name: result.name } + }); + } catch (error) { + logError(req, "households.joinHousehold", error, { invite_last4: inviteLast4 }); + sendError(res, 500, "Failed to join household"); + } +}; + +// Get household members +exports.getMembers = async (req, res) => { + try { + const members = await householdModel.getHouseholdMembers(req.params.householdId); + res.json(members); + } catch (error) { + logError(req, "households.getMembers", error); + sendError(res, 500, "Failed to fetch members"); + } +}; + +// Update member role +exports.updateMemberRole = async (req, res) => { + try { + const { userId } = req.params; + const { role } = req.body; + + if (!role || !["owner", "admin", "member"].includes(role)) { + return sendError(res, 400, "Invalid role. Must be 'owner', 'admin', or 'member'"); + } + + // Can't change own role + if (parseInt(userId) === req.user.id) { + return sendError(res, 400, "Cannot change your own role"); + } + + const targetRole = await householdModel.getUserRole(req.params.householdId, userId); + if (!targetRole) { + return sendError(res, 404, "Member not found"); + } + if (targetRole === "owner") { + return sendError(res, 403, "Owner role cannot be changed"); + } + + let updated; + if (role === "owner") { + if (req.household.role !== "owner") { + return sendError(res, 403, "Only the household owner can transfer ownership"); + } + + updated = await householdModel.transferOwnership( + req.params.householdId, + req.user.id, + parseInt(userId, 10) + ); + } else { + updated = await householdModel.updateMemberRole( + req.params.householdId, + userId, + role + ); + } + + res.json({ + message: role === "owner" + ? "Household ownership transferred successfully" + : "Member role updated successfully", + member: updated + }); + } catch (error) { + logError(req, "households.updateMemberRole", error); + sendError(res, 500, "Failed to update member role"); + } +}; + +// Remove member +exports.removeMember = async (req, res) => { + try { + const { userId } = req.params; + const targetUserId = parseInt(userId); + + // Allow users to remove themselves, or admins to remove others + if (targetUserId !== req.user.id && !["owner", "admin"].includes(req.household.role)) { + return sendError(res, 403, "Only admins or owners can remove other members"); + } + + const targetRole = await householdModel.getUserRole(req.params.householdId, userId); + if (targetRole === "owner") { + return sendError(res, 403, "Owner cannot be removed"); + } + + await householdModel.removeMember(req.params.householdId, userId); + + res.json({ message: "Member removed successfully" }); + } catch (error) { + logError(req, "households.removeMember", error); + sendError(res, 500, "Failed to remove member"); + } +}; diff --git a/backend/controllers/lists.controller.js b/backend/controllers/lists.controller.js index b33f7b7..90b92bf 100644 --- a/backend/controllers/lists.controller.js +++ b/backend/controllers/lists.controller.js @@ -1,5 +1,7 @@ -const List = require("../models/list.model"); -const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); +const List = require("../models/list.model"); +const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); +const { sendError } = require("../utils/http"); +const { logError } = require("../utils/logger"); exports.getList = async (req, res) => { @@ -57,9 +59,9 @@ exports.updateItemImage = async (req, res) => { const imageBuffer = req.processedImage?.buffer || null; const mimeType = req.processedImage?.mimeType || null; - if (!imageBuffer) { - return res.status(400).json({ message: "No image provided" }); - } + if (!imageBuffer) { + return sendError(res, 400, "No image provided"); + } // Update the item with new image await List.addOrUpdateItem(itemName, quantity, userId, imageBuffer, mimeType); @@ -89,17 +91,17 @@ exports.updateItemWithClassification = async (req, res) => { const { item_type, item_group, zone } = classification; // Validate classification data - if (item_type && !isValidItemType(item_type)) { - return res.status(400).json({ message: "Invalid item_type" }); - } - - if (item_group && !isValidItemGroup(item_type, item_group)) { - return res.status(400).json({ message: "Invalid item_group for selected item_type" }); - } - - if (zone && !isValidZone(zone)) { - return res.status(400).json({ message: "Invalid zone" }); - } + if (item_type && !isValidItemType(item_type)) { + return sendError(res, 400, "Invalid item_type"); + } + + if (item_group && !isValidItemGroup(item_type, item_group)) { + return sendError(res, 400, "Invalid item_group for selected item_type"); + } + + if (zone && !isValidZone(zone)) { + return sendError(res, 400, "Invalid zone"); + } // Upsert classification with confidence=1.0 and source='user' await List.upsertClassification(id, { @@ -112,8 +114,8 @@ exports.updateItemWithClassification = async (req, res) => { } res.json({ message: "Item updated successfully" }); - } catch (error) { - console.error("Error updating item with classification:", error); - res.status(500).json({ message: "Failed to update item" }); - } -}; \ No newline at end of file + } catch (error) { + logError(req, "listsLegacy.updateItemWithClassification", error); + sendError(res, 500, "Failed to update item"); + } +}; diff --git a/backend/controllers/lists.controller.v2.js b/backend/controllers/lists.controller.v2.js new file mode 100644 index 0000000..8295fdb --- /dev/null +++ b/backend/controllers/lists.controller.v2.js @@ -0,0 +1,396 @@ +const List = require("../models/list.model.v2"); +const householdModel = require("../models/household.model"); +const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); +const { sendError } = require("../utils/http"); +const { logError } = require("../utils/logger"); + +const LEGACY_ITEM_TYPE_MAP = { + beverages: "beverage", + snacks: "snack", +}; + +function normalizeClassificationPayload(classification) { + if (typeof classification === "string") { + const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification; + return { + item_type: normalizedItemType, + item_group: null, + zone: null, + }; + } + + if (!classification || typeof classification !== "object" || Array.isArray(classification)) { + return null; + } + + const item_type = + typeof classification.item_type === "string" && classification.item_type.trim() !== "" + ? classification.item_type.trim() + : null; + const item_group = + typeof classification.item_group === "string" && classification.item_group.trim() !== "" + ? classification.item_group.trim() + : null; + const zone = + typeof classification.zone === "string" && classification.zone.trim() !== "" + ? classification.zone.trim() + : null; + + if (!item_type && !item_group && !zone) { + return null; + } + + return { item_type, item_group, zone }; +} + +/** + * Get list items for household and store + * GET /households/:householdId/stores/:storeId/list + */ +exports.getList = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const items = await List.getHouseholdStoreList(householdId, storeId); + res.json({ items }); + } catch (error) { + logError(req, "listsV2.getList", error); + sendError(res, 500, "Failed to get list"); + } +}; + +/** + * Get specific item by name + * GET /households/:householdId/stores/:storeId/list/item + */ +exports.getItemByName = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name } = req.query; + + if (!item_name) { + return sendError(res, 400, "Item name is required"); + } + + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) { + return sendError(res, 404, "Item not found"); + } + + res.json(item); + } catch (error) { + logError(req, "listsV2.getItemByName", error); + sendError(res, 500, "Failed to get item"); + } +}; + +/** + * Add or update item in household store list + * POST /households/:householdId/stores/:storeId/list/add + */ +exports.addItem = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, quantity, notes, added_for_user_id } = req.body; + const userId = req.user.id; + let historyUserId = userId; + + if (!item_name || item_name.trim() === "") { + return sendError(res, 400, "Item name is required"); + } + + if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") { + const rawAddedForUserId = String(added_for_user_id).trim(); + if (!/^\d+$/.test(rawAddedForUserId)) { + return sendError(res, 400, "Added-for user ID must be a positive integer"); + } + + const parsedUserId = Number.parseInt(rawAddedForUserId, 10); + + if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) { + return sendError(res, 400, "Added-for user ID must be a positive integer"); + } + + const isMember = await householdModel.isHouseholdMember(householdId, parsedUserId); + if (!isMember) { + return sendError(res, 400, "Selected user is not a member of this household"); + } + + historyUserId = parsedUserId; + } + + // Get processed image if uploaded + const imageBuffer = req.processedImage?.buffer || null; + const mimeType = req.processedImage?.mimeType || null; + + const result = await List.addOrUpdateItem( + householdId, + storeId, + item_name, + quantity || "1", + userId, + imageBuffer, + mimeType, + notes + ); + + // Add history record + await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId); + + res.json({ + message: result.isNew ? "Item added" : "Item updated", + item: { + id: result.listId, + item_name: result.itemName, + quantity: quantity || "1", + bought: false + } + }); + } catch (error) { + logError(req, "listsV2.addItem", error); + sendError(res, 500, "Failed to add item"); + } +}; + +/** + * Mark item as bought or unbought + * PATCH /households/:householdId/stores/:storeId/list/item + */ +exports.markBought = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, bought, quantity_bought } = req.body; + + if (!item_name) return sendError(res, 400, "Item name is required"); + + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) return sendError(res, 404, "Item not found"); + + + // Update bought status (with optional partial purchase) + await List.setBought(item.id, bought, quantity_bought); + + res.json({ message: bought ? "Item marked as bought" : "Item unmarked" }); + } catch (error) { + logError(req, "listsV2.markBought", error); + sendError(res, 500, "Failed to update item"); + } +}; + +/** + * Update item details (quantity, notes) + * PUT /households/:householdId/stores/:storeId/list/item + */ +exports.updateItem = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, quantity, notes } = req.body; + + if (!item_name) { + return sendError(res, 400, "Item name is required"); + } + + // Get the list item + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) { + return sendError(res, 404, "Item not found"); + } + + // Update item + await List.updateItem(item.id, item_name, quantity, notes); + + res.json({ + message: "Item updated", + item: { + id: item.id, + item_name, + quantity, + notes + } + }); + } catch (error) { + logError(req, "listsV2.updateItem", error); + sendError(res, 500, "Failed to update item"); + } +}; + +/** + * Delete item from list + * DELETE /households/:householdId/stores/:storeId/list/item + */ +exports.deleteItem = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name } = req.body; + + if (!item_name) { + return sendError(res, 400, "Item name is required"); + } + + // Get the list item + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) { + return sendError(res, 404, "Item not found"); + } + + await List.deleteItem(item.id); + + res.json({ message: "Item deleted" }); + } catch (error) { + logError(req, "listsV2.deleteItem", error); + sendError(res, 500, "Failed to delete item"); + } +}; + +/** + * Get item suggestions based on query + * GET /households/:householdId/stores/:storeId/list/suggestions + */ +exports.getSuggestions = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { query } = req.query; + + const suggestions = await List.getSuggestions(query || "", householdId, storeId); + res.json(suggestions); + } catch (error) { + logError(req, "listsV2.getSuggestions", error); + sendError(res, 500, "Failed to get suggestions"); + } +}; + +/** + * Get recently bought items + * GET /households/:householdId/stores/:storeId/list/recent + */ +exports.getRecentlyBought = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const items = await List.getRecentlyBoughtItems(householdId, storeId); + res.json(items); + } catch (error) { + logError(req, "listsV2.getRecentlyBought", error); + sendError(res, 500, "Failed to get recent items"); + } +}; + +/** + * Get item classification + * GET /households/:householdId/stores/:storeId/list/classification + */ +exports.getClassification = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name } = req.query; + + if (!item_name) { + return sendError(res, 400, "Item name is required"); + } + + // Get item ID from name + const item = await List.getItemByName(householdId, storeId, item_name); + if (!item) { + return res.json({ classification: null }); + } + + const classification = await List.getClassification(householdId, storeId, item.item_id); + res.json({ classification }); + } catch (error) { + logError(req, "listsV2.getClassification", error); + sendError(res, 500, "Failed to get classification"); + } +}; + +/** + * Set/update item classification + * POST /households/:householdId/stores/:storeId/list/classification + */ +exports.setClassification = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, classification } = req.body; + + if (!item_name) { + return sendError(res, 400, "Item name is required"); + } + + const normalizedClassification = normalizeClassificationPayload(classification); + if (!normalizedClassification) { + return sendError(res, 400, "Classification is required"); + } + + const { item_type, item_group, zone } = normalizedClassification; + + if (item_type && !isValidItemType(item_type)) { + return sendError(res, 400, "Invalid item_type"); + } + + if (item_group && !item_type) { + return sendError(res, 400, "Item type is required when item group is provided"); + } + + if (item_group && !isValidItemGroup(item_type, item_group)) { + return sendError(res, 400, "Invalid item_group for selected item_type"); + } + + if (zone && !isValidZone(zone)) { + return sendError(res, 400, "Invalid zone"); + } + + // Get item - add to master items if not exists + const item = await List.getItemByName(householdId, storeId, item_name); + let itemId; + + if (!item) { + // Item doesn't exist in list, need to get from items table or create + const itemResult = await List.ensureHouseholdStoreItem( + householdId, + storeId, + item_name + ); + itemId = itemResult.id; + } else { + itemId = item.item_id; + } + + await List.upsertClassification(householdId, storeId, itemId, { + item_type, + item_group, + zone, + confidence: 1.0, + source: "user", + }); + + res.json({ message: "Classification set", classification: normalizedClassification }); + } catch (error) { + logError(req, "listsV2.setClassification", error); + sendError(res, 500, "Failed to set classification"); + } +}; + +/** + * Update item image + * POST /households/:householdId/stores/:storeId/list/update-image + */ +exports.updateItemImage = async (req, res) => { + try { + const { householdId, storeId } = req.params; + const { item_name, quantity } = req.body; + const userId = req.user.id; + + // Get processed image + const imageBuffer = req.processedImage?.buffer || null; + const mimeType = req.processedImage?.mimeType || null; + + if (!imageBuffer) { + return sendError(res, 400, "No image provided"); + } + + // Update the item with new image + await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType); + + res.json({ message: "Image updated successfully" }); + } catch (error) { + logError(req, "listsV2.updateItemImage", error); + sendError(res, 500, "Failed to update image"); + } +}; diff --git a/backend/controllers/stores.controller.js b/backend/controllers/stores.controller.js new file mode 100644 index 0000000..6e4adee --- /dev/null +++ b/backend/controllers/stores.controller.js @@ -0,0 +1,147 @@ +const storeModel = require("../models/store.model"); +const { sendError } = require("../utils/http"); +const { logError } = require("../utils/logger"); + +// Get all available stores +exports.getAllStores = async (req, res) => { + try { + const stores = await storeModel.getAllStores(); + res.json(stores); + } catch (error) { + logError(req, "stores.getAllStores", error); + sendError(res, 500, "Failed to fetch stores"); + } +}; + +// Get stores for household +exports.getHouseholdStores = async (req, res) => { + try { + const stores = await storeModel.getHouseholdStores(req.params.householdId); + res.json(stores); + } catch (error) { + logError(req, "stores.getHouseholdStores", error); + sendError(res, 500, "Failed to fetch household stores"); + } +}; + +// Add store to household +exports.addStoreToHousehold = async (req, res) => { + try { + const { storeId, isDefault } = req.body; + // console.log("Adding store to household:", { householdId: req.params.householdId, storeId, isDefault }); + if (!storeId) { + return sendError(res, 400, "Store ID is required"); + } + + const store = await storeModel.getStoreById(storeId); + if (!store) return sendError(res, 404, "Store not found"); + const foundStores = await storeModel.getHouseholdStores(req.params.householdId); + // if (foundStores.length == 0) isDefault = 'true'; + + await storeModel.addStoreToHousehold( + req.params.householdId, + storeId, + foundStores.length == 0 ? true : isDefault || false + ); + + res.status(201).json({ + message: "Store added to household successfully", + store + }); + } catch (error) { + logError(req, "stores.addStoreToHousehold", error); + sendError(res, 500, "Failed to add store to household"); + } +}; + +// Remove store from household +exports.removeStoreFromHousehold = async (req, res) => { + try { + await storeModel.removeStoreFromHousehold( + req.params.householdId, + req.params.storeId + ); + + res.json({ message: "Store removed from household successfully" }); + } catch (error) { + logError(req, "stores.removeStoreFromHousehold", error); + sendError(res, 500, "Failed to remove store from household"); + } +}; + +// Set default store +exports.setDefaultStore = async (req, res) => { + try { + await storeModel.setDefaultStore( + req.params.householdId, + req.params.storeId + ); + + res.json({ message: "Default store updated successfully" }); + } catch (error) { + logError(req, "stores.setDefaultStore", error); + sendError(res, 500, "Failed to set default store"); + } +}; + +// Create store (system admin only) +exports.createStore = async (req, res) => { + try { + const { name, default_zones } = req.body; + + if (!name || name.trim().length === 0) { + return sendError(res, 400, "Store name is required"); + } + + const store = await storeModel.createStore(name.trim(), default_zones || null); + + res.status(201).json({ + message: "Store created successfully", + store + }); + } catch (error) { + logError(req, "stores.createStore", error); + if (error.code === '23505') { // Unique violation + return sendError(res, 400, "Store with this name already exists"); + } + sendError(res, 500, "Failed to create store"); + } +}; + +// Update store (system admin only) +exports.updateStore = async (req, res) => { + try { + const { name, default_zones } = req.body; + + const store = await storeModel.updateStore(req.params.storeId, { + name: name?.trim(), + default_zones + }); + + if (!store) { + return sendError(res, 404, "Store not found"); + } + + res.json({ + message: "Store updated successfully", + store + }); + } catch (error) { + logError(req, "stores.updateStore", error); + sendError(res, 500, "Failed to update store"); + } +}; + +// Delete store (system admin only) +exports.deleteStore = async (req, res) => { + try { + await storeModel.deleteStore(req.params.storeId); + res.json({ message: "Store deleted successfully" }); + } catch (error) { + logError(req, "stores.deleteStore", error); + if (error.message.includes('in use')) { + return sendError(res, 400, error.message); + } + sendError(res, 500, "Failed to delete store"); + } +}; diff --git a/backend/controllers/users.controller.js b/backend/controllers/users.controller.js index 033a6c8..16a4093 100644 --- a/backend/controllers/users.controller.js +++ b/backend/controllers/users.controller.js @@ -1,50 +1,50 @@ -const User = require("../models/user.model"); -const bcrypt = require("bcryptjs"); +const User = require("../models/user.model"); +const bcrypt = require("bcryptjs"); +const { sendError } = require("../utils/http"); +const { logError } = require("../utils/logger"); -exports.test = async (req, res) => { - console.log("User route is working"); - res.json({ message: "User route is working" }); -}; +exports.test = async (req, res) => { + res.json({ message: "User route is working" }); +}; exports.getAllUsers = async (req, res) => { - console.log(req); const users = await User.getAllUsers(); res.json(users); }; exports.updateUserRole = async (req, res) => { - try { - const { id, role } = req.body; - - console.log(`Updating user ${id} to role ${role}`); - if (!Object.values(User.ROLES).includes(role)) - return res.status(400).json({ error: "Invalid role" }); - - const updated = await User.updateUserRole(id, role); - if (!updated) - return res.status(404).json({ error: "User not found" }); + try { + const { id, role } = req.body; + if (!Object.values(User.ROLES).includes(role)) + return sendError(res, 400, "Invalid role"); + + const updated = await User.updateUserRole(id, role); + if (!updated) + return sendError(res, 404, "User not found"); res.json({ message: "Role updated", id, role }); - } catch (err) { - res.status(500).json({ error: "Failed to update role" }); - } -}; + } catch (err) { + logError(req, "users.updateUserRole", err); + sendError(res, 500, "Failed to update role"); + } +}; exports.deleteUser = async (req, res) => { try { const { id } = req.params; - const deleted = await User.deleteUser(id); - if (!deleted) - return res.status(404).json({ error: "User not found" }); + const deleted = await User.deleteUser(id); + if (!deleted) + return sendError(res, 404, "User not found"); res.json({ message: "User deleted", id }); - } catch (err) { - res.status(500).json({ error: "Failed to delete user" }); - } -}; + } catch (err) { + logError(req, "users.deleteUser", err); + sendError(res, 500, "Failed to delete user"); + } +}; exports.checkIfUserExists = async (req, res) => { const { username } = req.query; @@ -57,42 +57,42 @@ exports.getCurrentUser = async (req, res) => { const userId = req.user.id; const user = await User.getUserById(userId); - if (!user) { - return res.status(404).json({ error: "User not found" }); - } + if (!user) { + return sendError(res, 404, "User not found"); + } res.json(user); - } catch (err) { - console.error("Error getting current user:", err); - res.status(500).json({ error: "Failed to get user profile" }); - } -}; + } catch (err) { + logError(req, "users.getCurrentUser", err); + sendError(res, 500, "Failed to get user profile"); + } +}; exports.updateCurrentUser = async (req, res) => { try { const userId = req.user.id; const { display_name } = req.body; - if (!display_name || display_name.trim().length === 0) { - return res.status(400).json({ error: "Display name is required" }); - } - - if (display_name.length > 100) { - return res.status(400).json({ error: "Display name must be 100 characters or less" }); - } + if (!display_name || display_name.trim().length === 0) { + return sendError(res, 400, "Display name is required"); + } + + if (display_name.length > 100) { + return sendError(res, 400, "Display name must be 100 characters or less"); + } const updated = await User.updateUserProfile(userId, { display_name: display_name.trim() }); - if (!updated) { - return res.status(404).json({ error: "User not found" }); - } + if (!updated) { + return sendError(res, 404, "User not found"); + } res.json({ message: "Profile updated successfully", user: updated }); - } catch (err) { - console.error("Error updating user profile:", err); - res.status(500).json({ error: "Failed to update profile" }); - } -}; + } catch (err) { + logError(req, "users.updateCurrentUser", err); + sendError(res, 500, "Failed to update profile"); + } +}; exports.changePassword = async (req, res) => { try { @@ -100,27 +100,27 @@ exports.changePassword = async (req, res) => { const { current_password, new_password } = req.body; // Validation - if (!current_password || !new_password) { - return res.status(400).json({ error: "Current password and new password are required" }); - } - - if (new_password.length < 6) { - return res.status(400).json({ error: "New password must be at least 6 characters" }); - } + if (!current_password || !new_password) { + return sendError(res, 400, "Current password and new password are required"); + } + + if (new_password.length < 6) { + return sendError(res, 400, "New password must be at least 6 characters"); + } // Get current password hash const currentHash = await User.getUserPasswordHash(userId); - if (!currentHash) { - return res.status(404).json({ error: "User not found" }); - } + if (!currentHash) { + return sendError(res, 404, "User not found"); + } // Verify current password const isValidPassword = await bcrypt.compare(current_password, currentHash); - if (!isValidPassword) { - return res.status(401).json({ error: "Current password is incorrect" }); - } + if (!isValidPassword) { + return sendError(res, 401, "Current password is incorrect"); + } // Hash new password const salt = await bcrypt.genSalt(10); @@ -130,8 +130,8 @@ exports.changePassword = async (req, res) => { await User.updateUserPassword(userId, hashedPassword); res.json({ message: "Password changed successfully" }); - } catch (err) { - console.error("Error changing password:", err); - res.status(500).json({ error: "Failed to change password" }); - } -}; + } catch (err) { + logError(req, "users.changePassword", err); + sendError(res, 500, "Failed to change password"); + } +}; diff --git a/backend/db/pool.js b/backend/db/pool.js index 38df9bf..38f0286 100644 --- a/backend/db/pool.js +++ b/backend/db/pool.js @@ -1,11 +1,21 @@ const { Pool } = require("pg"); -const pool = new Pool({ - user: process.env.DB_USER, - password: process.env.DB_PASS, - host: process.env.DB_HOST, - database: process.env.DB_NAME, - port: 5432, -}); +function buildPoolConfig() { + if (process.env.DATABASE_URL) { + return { + connectionString: process.env.DATABASE_URL, + }; + } + + return { + user: process.env.DB_USER, + password: process.env.DB_PASS, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + port: Number(process.env.DB_PORT || 5432), + }; +} + +const pool = new Pool(buildPoolConfig()); module.exports = pool; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 8e2caf1..08e1ae6 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,18 +1,54 @@ const jwt = require("jsonwebtoken"); +const { sendError } = require("../utils/http"); +const Session = require("../models/session.model"); +const { parseCookieHeader } = require("../utils/cookies"); +const { cookieName } = require("../utils/session-cookie"); +const { logError } = require("../utils/logger"); -function auth(req, res, next) { - const header = req.headers.authorization; - if (!header) return res.status(401).json({ message: "Missing token" }); +async function auth(req, res, next) { + const header = req.headers.authorization || ""; + const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null; - const token = header.split(" ")[1]; - if (!token) return res.status(401).json({ message: "Invalid token format" }); + if (token) { + const jwtSecret = process.env.JWT_SECRET; + if (!jwtSecret) { + logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured")); + return sendError(res, 500, "Authentication is unavailable"); + } + + try { + const decoded = jwt.verify(token, jwtSecret); + req.user = decoded; // id + role + return next(); + } catch (err) { + return sendError(res, 401, "Invalid or expired token"); + } + } try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - req.user = decoded; // id + role - next(); + const cookies = parseCookieHeader(req.headers.cookie); + const sid = cookies[cookieName()]; + + if (!sid) { + return sendError(res, 401, "Missing authentication"); + } + + const session = await Session.getActiveSessionWithUser(sid); + if (!session) { + return sendError(res, 401, "Invalid or expired session"); + } + + req.user = { + id: session.user_id, + role: session.role, + username: session.username, + }; + req.session_id = session.id; + + return next(); } catch (err) { - res.status(401).json({ message: "Invalid or expired token" }); + logError(req, "middleware.auth", err); + return sendError(res, 500, "Authentication check failed"); } } diff --git a/backend/middleware/household.js b/backend/middleware/household.js new file mode 100644 index 0000000..8a2ece0 --- /dev/null +++ b/backend/middleware/household.js @@ -0,0 +1,104 @@ +const householdModel = require("../models/household.model"); +const { sendError } = require("../utils/http"); +const { logError } = require("../utils/logger"); + +// Middleware to check if user belongs to household +exports.householdAccess = async (req, res, next) => { + try { + const householdId = parseInt(req.params.householdId || req.params.hId); + const userId = req.user.id; + + if (!householdId) { + return sendError(res, 400, "Household ID required"); + } + + // Check if user is member of household + const isMember = await householdModel.isHouseholdMember(householdId, userId); + + if (!isMember) { + return sendError(res, 403, "Access denied. You are not a member of this household."); + } + + // Get user's role in household + const role = await householdModel.getUserRole(householdId, userId); + + // Attach household info to request + req.household = { + id: householdId, + role: role + }; + + next(); + } catch (error) { + logError(req, "middleware.householdAccess", error); + sendError(res, 500, "Server error checking household access"); + } +}; + +// Middleware to require specific household role(s) +exports.requireHouseholdRole = (...allowedRoles) => { + return (req, res, next) => { + if (!req.household) { + return sendError(res, 500, "Household context not set. Use householdAccess middleware first."); + } + + if (!allowedRoles.includes(req.household.role)) { + return sendError( + res, + 403, + `Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}` + ); + } + + next(); + }; +}; + +// Middleware to require admin/owner role in household +exports.requireHouseholdAdmin = exports.requireHouseholdRole('owner', 'admin'); + +// Middleware to check store access (household must have store) +exports.storeAccess = async (req, res, next) => { + try { + const storeId = parseInt(req.params.storeId || req.params.sId); + + if (!storeId) { + return sendError(res, 400, "Store ID required"); + } + + if (!req.household) { + return sendError(res, 500, "Household context not set. Use householdAccess middleware first."); + } + + // Check if household has access to this store + const storeModel = require("../models/store.model"); + const hasStore = await storeModel.householdHasStore(req.household.id, storeId); + + if (!hasStore) { + return sendError(res, 403, "This household does not have access to this store."); + } + + // Attach store info to request + req.store = { + id: storeId + }; + + next(); + } catch (error) { + logError(req, "middleware.storeAccess", error); + sendError(res, 500, "Server error checking store access"); + } +}; + +// Middleware to require system admin role +exports.requireSystemAdmin = (req, res, next) => { + if (!req.user) { + return sendError(res, 401, "Authentication required"); + } + + if (req.user.role !== 'system_admin') { + return sendError(res, 403, "Access denied. System administrator privileges required."); + } + + next(); +}; diff --git a/backend/middleware/image.js b/backend/middleware/image.js index 2983ced..8ae50d4 100644 --- a/backend/middleware/image.js +++ b/backend/middleware/image.js @@ -1,6 +1,7 @@ const multer = require("multer"); const sharp = require("sharp"); const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants"); +const { sendError } = require("../utils/http"); // Configure multer for memory storage (we'll process before saving to DB) const upload = multer({ @@ -42,7 +43,7 @@ const processImage = async (req, res, next) => { next(); } catch (error) { - res.status(400).json({ message: "Error processing image: " + error.message }); + sendError(res, 400, `Error processing image: ${error.message}`); } }; diff --git a/backend/middleware/optional-auth.js b/backend/middleware/optional-auth.js new file mode 100644 index 0000000..ddbf932 --- /dev/null +++ b/backend/middleware/optional-auth.js @@ -0,0 +1,47 @@ +const jwt = require("jsonwebtoken"); +const Session = require("../models/session.model"); +const { parseCookieHeader } = require("../utils/cookies"); +const { cookieName } = require("../utils/session-cookie"); +const { logError } = require("../utils/logger"); + +async function optionalAuth(req, res, next) { + const header = req.headers.authorization || ""; + const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null; + + if (token) { + const jwtSecret = process.env.JWT_SECRET; + if (!jwtSecret) { + return next(); + } + + try { + const decoded = jwt.verify(token, jwtSecret); + req.user = decoded; + return next(); + } catch (err) { + return next(); + } + } + + try { + const cookies = parseCookieHeader(req.headers.cookie); + const sid = cookies[cookieName()]; + if (!sid) return next(); + + const session = await Session.getActiveSessionWithUser(sid); + if (!session) return next(); + + req.user = { + id: session.user_id, + role: session.role, + username: session.username, + }; + req.session_id = session.id; + } catch (err) { + logError(req, "middleware.optionalAuth", err); + } + + return next(); +} + +module.exports = optionalAuth; diff --git a/backend/middleware/rate-limit.js b/backend/middleware/rate-limit.js new file mode 100644 index 0000000..4d17458 --- /dev/null +++ b/backend/middleware/rate-limit.js @@ -0,0 +1,59 @@ +const { sendError } = require("../utils/http"); + +const buckets = new Map(); + +function pruneExpired(now) { + for (const [key, value] of buckets.entries()) { + if (value.resetAt <= now) { + buckets.delete(key); + } + } +} + +function getClientIp(req) { + const forwardedFor = req.headers["x-forwarded-for"]; + if (typeof forwardedFor === "string" && forwardedFor.trim()) { + return forwardedFor.split(",")[0].trim(); + } + return req.ip || req.socket?.remoteAddress || "unknown"; +} + +function createRateLimit({ keyPrefix, windowMs, max, message, keyFn }) { + return (req, res, next) => { + const now = Date.now(); + + if (buckets.size > 5000) { + pruneExpired(now); + } + + const suffix = typeof keyFn === "function" ? keyFn(req) : getClientIp(req); + const key = `${keyPrefix}:${suffix || "unknown"}`; + const existing = buckets.get(key); + const bucket = + !existing || existing.resetAt <= now + ? { count: 0, resetAt: now + windowMs } + : existing; + + bucket.count += 1; + buckets.set(key, bucket); + + if (bucket.count > max) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((bucket.resetAt - now) / 1000) + ); + res.setHeader("Retry-After", String(retryAfterSeconds)); + return sendError( + res, + 429, + message || "Too many requests. Please try again later." + ); + } + + return next(); + }; +} + +module.exports = { + createRateLimit, +}; diff --git a/backend/middleware/rbac.js b/backend/middleware/rbac.js index 1d8205a..205de10 100644 --- a/backend/middleware/rbac.js +++ b/backend/middleware/rbac.js @@ -1,8 +1,10 @@ +const { sendError } = require("../utils/http"); + function requireRole(...allowedRoles) { return (req, res, next) => { - if (!req.user) return res.status(401).json({ message: "Authentication required" }); + if (!req.user) return sendError(res, 401, "Authentication required"); if (!allowedRoles.includes(req.user.role)) - return res.status(403).json({ message: "Forbidden" }); + return sendError(res, 403, "Forbidden"); next(); }; diff --git a/backend/middleware/request-id.js b/backend/middleware/request-id.js new file mode 100644 index 0000000..4995420 --- /dev/null +++ b/backend/middleware/request-id.js @@ -0,0 +1,47 @@ +const crypto = require("crypto"); +const { normalizeErrorPayload } = require("../utils/http"); + +function generateRequestId() { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return crypto.randomBytes(16).toString("hex"); +} + +function isPlainObject(value) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +function requestIdMiddleware(req, res, next) { + const requestId = generateRequestId(); + + req.request_id = requestId; + res.locals.request_id = requestId; + res.setHeader("X-Request-Id", requestId); + + const originalJson = res.json.bind(res); + res.json = (payload) => { + const normalizedPayload = normalizeErrorPayload(payload, res.statusCode); + + if (isPlainObject(normalizedPayload)) { + if (normalizedPayload.request_id === undefined) { + return originalJson({ ...normalizedPayload, request_id: requestId }); + } + return originalJson(normalizedPayload); + } + + return originalJson({ + data: normalizedPayload, + request_id: requestId, + }); + }; + + next(); +} + +module.exports = requestIdMiddleware; diff --git a/backend/migrations/MIGRATION_GUIDE.md b/backend/migrations/MIGRATION_GUIDE.md new file mode 100644 index 0000000..65604ca --- /dev/null +++ b/backend/migrations/MIGRATION_GUIDE.md @@ -0,0 +1,243 @@ +# Multi-Household Architecture Migration Guide + +## Pre-Migration Checklist + +- [ ] **Backup Database** + ```bash + pg_dump -U your_user -d grocery_list > backup_$(date +%Y%m%d_%H%M%S).sql + ``` + +- [ ] **Test on Staging First** + - Copy production database to staging environment + - Run migration on staging + - Verify all data migrated correctly + - Test application functionality + +- [ ] **Review Migration Script** + - Read through `multi_household_architecture.sql` + - Understand each step + - Note verification queries + +- [ ] **Announce Maintenance Window** + - Notify users of downtime + - Schedule during low-usage period + - Estimate 15-30 minutes for migration + +## Running the Migration + +### 1. Connect to Database + +```bash +psql -U your_user -d grocery_list +``` + +### 2. Run Migration + +```sql +\i backend/migrations/multi_household_architecture.sql +``` + +The script will: +1. ✅ Create 8 new tables +2. ✅ Create default "Main Household" +3. ✅ Create default "Costco" store +4. ✅ Migrate all users to household members +5. ✅ Extract items to master catalog +6. ✅ Migrate grocery_list → household_lists +7. ✅ Migrate classifications +8. ✅ Migrate history records +9. ✅ Update user system roles + +### 3. Verify Migration + +Run these queries inside psql: + +```sql +-- Check household created +SELECT * FROM households; + +-- Check all users migrated +SELECT u.username, u.role as system_role, hm.role as household_role +FROM users u +JOIN household_members hm ON u.id = hm.user_id +ORDER BY u.id; + +-- Check item counts match +SELECT + (SELECT COUNT(DISTINCT item_name) FROM grocery_list) as old_unique_items, + (SELECT COUNT(*) FROM items) as new_items; + +-- Check list counts +SELECT + (SELECT COUNT(*) FROM grocery_list) as old_lists, + (SELECT COUNT(*) FROM household_lists) as new_lists; + +-- Check classification counts +SELECT + (SELECT COUNT(*) FROM item_classification) as old_classifications, + (SELECT COUNT(*) FROM household_item_classifications) as new_classifications; + +-- Check history counts +SELECT + (SELECT COUNT(*) FROM grocery_history) as old_history, + (SELECT COUNT(*) FROM household_list_history) as new_history; + +-- Verify no data loss - check if all old items have corresponding new records +SELECT gl.item_name +FROM grocery_list gl +LEFT JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name)) +LEFT JOIN household_lists hl ON hl.item_id = i.id +WHERE hl.id IS NULL; +-- Should return 0 rows + +-- Check invite code +SELECT name, invite_code FROM households; +``` + +### 4. Test Application + +- [ ] Users can log in +- [ ] Can view "Main Household" list +- [ ] Can add items +- [ ] Can mark items as bought +- [ ] History shows correctly +- [ ] Classifications preserved +- [ ] Images display correctly + +## Post-Migration Cleanup + +**Only after verifying everything works correctly:** + +```sql +-- Drop old tables (CAREFUL - THIS IS IRREVERSIBLE) +DROP TABLE IF EXISTS grocery_history CASCADE; +DROP TABLE IF EXISTS item_classification CASCADE; +DROP TABLE IF EXISTS grocery_list CASCADE; +``` + +## Rollback Plan + +### If Migration Fails + +```sql +-- Inside psql during migration +ROLLBACK; + +-- Then restore from backup +\q +psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql +``` + +### If Issues Found After Migration + +```bash +# Drop the database and restore +dropdb grocery_list +createdb grocery_list +psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql +``` + +## Common Issues & Solutions + +### Issue: Duplicate items in items table +**Cause**: Case-insensitive matching not working +**Solution**: Check item names for leading/trailing spaces + +### Issue: Foreign key constraint errors +**Cause**: User or item references not found +**Solution**: Verify all users and items exist before migrating lists + +### Issue: History not showing +**Cause**: household_list_id references incorrect +**Solution**: Check JOIN conditions in history migration + +### Issue: Images not displaying +**Cause**: BYTEA encoding issues +**Solution**: Verify image_mime_type correctly migrated + +## Migration Timeline + +- **T-0**: Begin maintenance window +- **T+2min**: Backup complete +- **T+3min**: Start migration script +- **T+8min**: Migration complete (for ~1000 items) +- **T+10min**: Run verification queries +- **T+15min**: Test application functionality +- **T+20min**: If successful, announce completion +- **T+30min**: End maintenance window + +## Data Integrity Checks + +```sql +-- Ensure all users belong to at least one household +SELECT u.id, u.username +FROM users u +LEFT JOIN household_members hm ON u.id = hm.user_id +WHERE hm.id IS NULL; +-- Should return 0 rows + +-- Ensure all household lists have valid items +SELECT hl.id +FROM household_lists hl +LEFT JOIN items i ON hl.item_id = i.id +WHERE i.id IS NULL; +-- Should return 0 rows + +-- Ensure all history has valid list references +SELECT hlh.id +FROM household_list_history hlh +LEFT JOIN household_lists hl ON hlh.household_list_id = hl.id +WHERE hl.id IS NULL; +-- Should return 0 rows + +-- Check for orphaned classifications +SELECT hic.id +FROM household_item_classifications hic +LEFT JOIN household_lists hl ON hic.item_id = hl.item_id + AND hic.household_id = hl.household_id + AND hic.store_id = hl.store_id +WHERE hl.id IS NULL; +-- Should return 0 rows (or classifications for removed items, which is ok) +``` + +## Success Criteria + +✅ All tables created successfully +✅ All users migrated to "Main Household" +✅ Item count matches (unique items from old → new) +✅ List count matches (all grocery_list items → household_lists) +✅ Classification count matches +✅ History count matches +✅ No NULL foreign keys +✅ Application loads without errors +✅ Users can perform all CRUD operations +✅ Images display correctly +✅ Bought items still marked as bought +✅ Recently bought still shows correctly + +## Next Steps After Migration + +1. ✅ Update backend models (Sprint 2) +2. ✅ Update API routes +3. ✅ Update controllers +4. ✅ Test all endpoints +5. ✅ Update frontend contexts +6. ✅ Update UI components +7. ✅ Enable multi-household features + +## Support & Troubleshooting + +If issues arise: +1. Check PostgreSQL logs: `/var/log/postgresql/` +2. Check application logs +3. Restore from backup if needed +4. Review migration script for errors + +## Monitoring Post-Migration + +For the first 24 hours after migration: +- Monitor error logs +- Watch for performance issues +- Verify user activity normal +- Check for any data inconsistencies +- Be ready to rollback if critical issues found diff --git a/backend/migrations/add_image_columns.sql b/backend/migrations/add_image_columns.sql index 6f5a061..9777037 100644 --- a/backend/migrations/add_image_columns.sql +++ b/backend/migrations/add_image_columns.sql @@ -1,20 +1,8 @@ -# Database Migration: Add Image Support - -Run these SQL commands on your PostgreSQL database: - -```sql -- Add image columns to grocery_list table -ALTER TABLE grocery_list -ADD COLUMN item_image BYTEA, -ADD COLUMN image_mime_type VARCHAR(50); +ALTER TABLE grocery_list +ADD COLUMN IF NOT EXISTS item_image BYTEA, +ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50); --- Optional: Add index for faster queries when filtering by items with images -CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL)); -``` - -## To Verify: -```sql -\d grocery_list -``` - -You should see the new columns `item_image` and `image_mime_type`. +-- Index to speed up queries that filter by rows with images. +CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image +ON grocery_list ((item_image IS NOT NULL)); diff --git a/backend/migrations/add_notes_column.sql b/backend/migrations/add_notes_column.sql new file mode 100644 index 0000000..977dd22 --- /dev/null +++ b/backend/migrations/add_notes_column.sql @@ -0,0 +1,7 @@ +-- Add notes column to household_lists table +-- This allows users to add custom notes/descriptions to list items + +ALTER TABLE household_lists +ADD COLUMN IF NOT EXISTS notes TEXT; + +COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item'; diff --git a/backend/migrations/backups/backup_20260125_000426.sql b/backend/migrations/backups/backup_20260125_000426.sql new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/multi_household_architecture.sql b/backend/migrations/multi_household_architecture.sql new file mode 100644 index 0000000..b64a567 --- /dev/null +++ b/backend/migrations/multi_household_architecture.sql @@ -0,0 +1,397 @@ +-- ============================================================================ +-- Multi-Household & Multi-Store Architecture Migration +-- ============================================================================ +-- This migration transforms the single-list app into a multi-tenant system +-- supporting multiple households, each with multiple stores. +-- +-- IMPORTANT: Backup your database before running this migration! +-- pg_dump grocery_list > backup_$(date +%Y%m%d).sql +-- +-- Migration Strategy: +-- 1. Create new tables +-- 2. Create "Main Household" for existing users +-- 3. Migrate existing data to new structure +-- 4. Update roles (keep users.role for system admin) +-- 5. Verify data integrity +-- 6. (Manual step) Drop old tables after verification +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- STEP 1: CREATE NEW TABLES +-- ============================================================================ + +-- Households table +CREATE TABLE IF NOT EXISTS households ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + invite_code VARCHAR(20) UNIQUE NOT NULL, + code_expires_at TIMESTAMP +); + +CREATE INDEX idx_households_invite_code ON households(invite_code); +COMMENT ON TABLE households IS 'Household groups (families, roommates, etc.)'; +COMMENT ON COLUMN households.invite_code IS 'Unique code for inviting users to join household'; + +-- Store types table +CREATE TABLE IF NOT EXISTS stores ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + default_zones JSONB, + created_at TIMESTAMP DEFAULT NOW() +); + +COMMENT ON TABLE stores IS 'Store types/chains (Costco, Target, Walmart, etc.)'; +COMMENT ON COLUMN stores.default_zones IS 'JSON array of default zone names for this store type'; + +-- User-Household membership with per-household roles +CREATE TABLE IF NOT EXISTS household_members ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')), + joined_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, user_id) +); + +CREATE INDEX idx_household_members_user ON household_members(user_id); +CREATE INDEX idx_household_members_household ON household_members(household_id); +COMMENT ON TABLE household_members IS 'User membership in households with per-household roles'; +COMMENT ON COLUMN household_members.role IS 'admin: full control, user: standard member'; + +-- Household-Store relationship +CREATE TABLE IF NOT EXISTS household_stores ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + is_default BOOLEAN DEFAULT FALSE, + added_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id) +); + +CREATE INDEX idx_household_stores_household ON household_stores(household_id); +COMMENT ON TABLE household_stores IS 'Which stores each household shops at'; + +-- Master item catalog (shared across all households) +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + default_image BYTEA, + default_image_mime_type VARCHAR(50), + created_at TIMESTAMP DEFAULT NOW(), + usage_count INTEGER DEFAULT 0 +); + +CREATE INDEX idx_items_name ON items(name); +CREATE INDEX idx_items_usage_count ON items(usage_count DESC); +COMMENT ON TABLE items IS 'Master item catalog shared across all households'; +COMMENT ON COLUMN items.usage_count IS 'Popularity metric for suggestions'; + +-- Household-specific grocery lists (per store) +CREATE TABLE IF NOT EXISTS household_lists ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + item_id INTEGER REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL DEFAULT 1, + bought BOOLEAN DEFAULT FALSE, + custom_image BYTEA, + custom_image_mime_type VARCHAR(50), + added_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + modified_on TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id, item_id) +); + +CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id); +CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought); +CREATE INDEX idx_household_lists_modified ON household_lists(modified_on DESC); +COMMENT ON TABLE household_lists IS 'Grocery lists scoped to household + store combination'; + +-- Household-specific item classifications (per store) +CREATE TABLE IF NOT EXISTS household_item_classifications ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + item_id INTEGER REFERENCES items(id) ON DELETE CASCADE, + item_type VARCHAR(50), + item_group VARCHAR(100), + zone VARCHAR(100), + confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1), + source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id, item_id) +); + +CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id); +CREATE INDEX idx_household_classifications_type ON household_item_classifications(item_type); +CREATE INDEX idx_household_classifications_zone ON household_item_classifications(zone); +COMMENT ON TABLE household_item_classifications IS 'Item classifications scoped to household + store'; + +-- History tracking +CREATE TABLE IF NOT EXISTS household_list_history ( + id SERIAL PRIMARY KEY, + household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL, + added_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + added_on TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_household_history_list ON household_list_history(household_list_id); +CREATE INDEX idx_household_history_user ON household_list_history(added_by); +CREATE INDEX idx_household_history_date ON household_list_history(added_on DESC); +COMMENT ON TABLE household_list_history IS 'Tracks who added items and when'; + +-- ============================================================================ +-- STEP 2: CREATE DEFAULT HOUSEHOLD AND STORE +-- ============================================================================ + +-- Create default household for existing users +INSERT INTO households (name, created_by, invite_code) +SELECT + 'Main Household', + (SELECT id FROM users WHERE role = 'admin' LIMIT 1), -- First admin as creator + 'MAIN' || LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0') -- Random 6-digit code +WHERE NOT EXISTS (SELECT 1 FROM households WHERE name = 'Main Household'); + +-- Create default Costco store +INSERT INTO stores (name, default_zones) +VALUES ( + 'Costco', + '{ + "zones": [ + "Entrance & Seasonal", + "Fresh Produce", + "Meat & Seafood", + "Dairy & Refrigerated", + "Deli & Prepared Foods", + "Bakery & Bread", + "Frozen Foods", + "Beverages", + "Snacks & Candy", + "Pantry & Dry Goods", + "Health & Beauty", + "Household & Cleaning", + "Other" + ] + }'::jsonb +) +ON CONFLICT (name) DO NOTHING; + +-- Link default household to default store +INSERT INTO household_stores (household_id, store_id, is_default) +SELECT + (SELECT id FROM households WHERE name = 'Main Household'), + (SELECT id FROM stores WHERE name = 'Costco'), + TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM household_stores + WHERE household_id = (SELECT id FROM households WHERE name = 'Main Household') +); + +-- ============================================================================ +-- STEP 3: MIGRATE USERS TO HOUSEHOLD MEMBERS +-- ============================================================================ + +-- Add all existing users to Main Household +-- Old admins become household admins, others become standard users +INSERT INTO household_members (household_id, user_id, role) +SELECT + (SELECT id FROM households WHERE name = 'Main Household'), + id, + CASE + WHEN role = 'admin' THEN 'admin' + ELSE 'user' + END +FROM users +WHERE NOT EXISTS ( + SELECT 1 FROM household_members hm + WHERE hm.user_id = users.id + AND hm.household_id = (SELECT id FROM households WHERE name = 'Main Household') +); + +-- ============================================================================ +-- STEP 4: MIGRATE ITEMS TO MASTER CATALOG +-- ============================================================================ + +-- Extract unique items from grocery_list into master items table +INSERT INTO items (name, default_image, default_image_mime_type, created_at, usage_count) +SELECT + LOWER(TRIM(item_name)) as name, + item_image, + image_mime_type, + MIN(modified_on) as created_at, + COUNT(*) as usage_count +FROM grocery_list +WHERE NOT EXISTS ( + SELECT 1 FROM items WHERE LOWER(items.name) = LOWER(TRIM(grocery_list.item_name)) +) +GROUP BY LOWER(TRIM(item_name)), item_image, image_mime_type +ON CONFLICT (name) DO NOTHING; + +-- ============================================================================ +-- STEP 5: MIGRATE GROCERY_LIST TO HOUSEHOLD_LISTS +-- ============================================================================ + +-- Migrate current list to household_lists +INSERT INTO household_lists ( + household_id, + store_id, + item_id, + quantity, + bought, + custom_image, + custom_image_mime_type, + added_by, + modified_on +) +SELECT + (SELECT id FROM households WHERE name = 'Main Household'), + (SELECT id FROM stores WHERE name = 'Costco'), + i.id, + gl.quantity, + gl.bought, + CASE WHEN gl.item_image != i.default_image THEN gl.item_image ELSE NULL END, -- Only store if different + CASE WHEN gl.item_image != i.default_image THEN gl.image_mime_type ELSE NULL END, + gl.added_by, + gl.modified_on +FROM grocery_list gl +JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name)) +WHERE NOT EXISTS ( + SELECT 1 FROM household_lists hl + WHERE hl.household_id = (SELECT id FROM households WHERE name = 'Main Household') + AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco') + AND hl.item_id = i.id +) +ON CONFLICT (household_id, store_id, item_id) DO NOTHING; + +-- ============================================================================ +-- STEP 6: MIGRATE ITEM_CLASSIFICATION TO HOUSEHOLD_ITEM_CLASSIFICATIONS +-- ============================================================================ + +-- Migrate classifications +INSERT INTO household_item_classifications ( + household_id, + store_id, + item_id, + item_type, + item_group, + zone, + confidence, + source, + created_at, + updated_at +) +SELECT + (SELECT id FROM households WHERE name = 'Main Household'), + (SELECT id FROM stores WHERE name = 'Costco'), + i.id, + ic.item_type, + ic.item_group, + ic.zone, + ic.confidence, + ic.source, + ic.created_at, + ic.updated_at +FROM item_classification ic +JOIN grocery_list gl ON ic.id = gl.id +JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name)) +WHERE NOT EXISTS ( + SELECT 1 FROM household_item_classifications hic + WHERE hic.household_id = (SELECT id FROM households WHERE name = 'Main Household') + AND hic.store_id = (SELECT id FROM stores WHERE name = 'Costco') + AND hic.item_id = i.id +) +ON CONFLICT (household_id, store_id, item_id) DO NOTHING; + +-- ============================================================================ +-- STEP 7: MIGRATE GROCERY_HISTORY TO HOUSEHOLD_LIST_HISTORY +-- ============================================================================ + +-- Migrate history records +INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on) +SELECT + hl.id, + gh.quantity, + gh.added_by, + gh.added_on +FROM grocery_history gh +JOIN grocery_list gl ON gh.list_item_id = gl.id +JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name)) +JOIN household_lists hl ON hl.item_id = i.id + AND hl.household_id = (SELECT id FROM households WHERE name = 'Main Household') + AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco') +WHERE NOT EXISTS ( + SELECT 1 FROM household_list_history hlh + WHERE hlh.household_list_id = hl.id + AND hlh.added_by = gh.added_by + AND hlh.added_on = gh.added_on +); + +-- ============================================================================ +-- STEP 8: UPDATE USER ROLES (SYSTEM-WIDE) +-- ============================================================================ + +-- Update system roles: admin → system_admin, others → user +UPDATE users +SET role = 'system_admin' +WHERE role = 'admin'; + +UPDATE users +SET role = 'user' +WHERE role IN ('editor', 'viewer'); + +-- ============================================================================ +-- VERIFICATION QUERIES +-- ============================================================================ + +-- Run these to verify migration success: + +-- Check household created +-- SELECT * FROM households; + +-- Check all users added to household +-- SELECT u.username, u.role as system_role, hm.role as household_role +-- FROM users u +-- JOIN household_members hm ON u.id = hm.user_id +-- ORDER BY u.id; + +-- Check items migrated +-- SELECT COUNT(*) as total_items FROM items; +-- SELECT COUNT(*) as original_items FROM (SELECT DISTINCT item_name FROM grocery_list) sub; + +-- Check lists migrated +-- SELECT COUNT(*) as new_lists FROM household_lists; +-- SELECT COUNT(*) as old_lists FROM grocery_list; + +-- Check classifications migrated +-- SELECT COUNT(*) as new_classifications FROM household_item_classifications; +-- SELECT COUNT(*) as old_classifications FROM item_classification; + +-- Check history migrated +-- SELECT COUNT(*) as new_history FROM household_list_history; +-- SELECT COUNT(*) as old_history FROM grocery_history; + +-- ============================================================================ +-- MANUAL STEPS AFTER VERIFICATION +-- ============================================================================ + +-- After verifying data integrity, uncomment and run these to clean up: + +-- DROP TABLE IF EXISTS grocery_history CASCADE; +-- DROP TABLE IF EXISTS item_classification CASCADE; +-- DROP TABLE IF EXISTS grocery_list CASCADE; + +COMMIT; + +-- ============================================================================ +-- ROLLBACK (if something goes wrong) +-- ============================================================================ + +-- ROLLBACK; + +-- Then restore from backup: +-- psql -U your_user -d grocery_list < backup_YYYYMMDD.sql diff --git a/backend/migrations/stale-sql-report.json b/backend/migrations/stale-sql-report.json new file mode 100644 index 0000000..4277b3e --- /dev/null +++ b/backend/migrations/stale-sql-report.json @@ -0,0 +1,99 @@ +{ + "generated_at": "2026-05-25T23:06:21.741Z", + "canonical_dir": "packages\\db\\migrations", + "legacy_dir": "backend\\migrations", + "stale_sql_files": [ + { + "filename": "add_display_name_column.sql", + "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, + "backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f", + "canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f", + "normalized_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f" + }, + { + "filename": "add_image_columns.sql", + "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, + "backend_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded", + "canonical_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded", + "normalized_sha256": "753cf2524b15cb14055ad94e0f344ad69e8b45110ae338baf764879f69ebfded" + }, + { + "filename": "add_modified_on_column.sql", + "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, + "backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b", + "canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b", + "normalized_sha256": "cf4f5dcd2e470954499fc5a191428401bda033d2d32f4851b5674530e56e9b08" + }, + { + "filename": "add_notes_column.sql", + "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, + "backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a", + "canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a", + "normalized_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a" + }, + { + "filename": "create_item_classification_table.sql", + "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, + "backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a", + "canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a", + "normalized_sha256": "473e804290863e92ae4d732d4a241be96e827c3194139e32172f6012caf60c50" + }, + { + "filename": "multi_household_architecture.sql", + "status": "STALE_DUPLICATE_OF_CANONICAL", + "requires_action": false, + "backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e", + "canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e", + "normalized_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e" + } + ], + "canonical_only_sql_files": [ + { + "filename": "20260328_010000_add_household_store_available_items.sql", + "status": "CANONICAL_ONLY", + "requires_action": false, + "canonical_sha256": "58eaf6b526e0317edd45083ba64432fb973ab4a489c0bfd320c422ee501a6206" + }, + { + "filename": "20260329_010000_add_household_store_items.sql", + "status": "CANONICAL_ONLY", + "requires_action": false, + "canonical_sha256": "4421515183150c388b19dde66e682807269fbc31414cc1ccfc095abab3788188" + }, + { + "filename": "20260329_020000_fix_household_item_classification_upsert.sql", + "status": "CANONICAL_ONLY", + "requires_action": false, + "canonical_sha256": "8c86cde57bf98b0c9bf5340d685150e89a2fdb873d1bda83893506b2b2478e62" + }, + { + "filename": "create_sessions_table.sql", + "status": "CANONICAL_ONLY", + "requires_action": false, + "canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030" + }, + { + "filename": "zz_group_invites_and_join_policies.sql", + "status": "CANONICAL_ONLY", + "requires_action": false, + "canonical_sha256": "47e31807356c6682a926aa0d9fd9c46b9edf0b8a586d6c39a36c931e5de5ca5b" + } + ], + "legacy_non_sql_files": [ + "MIGRATION_GUIDE.md", + "stale-sql-report.json" + ], + "summary": { + "stale_total": 6, + "stale_only_in_backend_total": 0, + "stale_duplicate_total": 6, + "stale_diverged_total": 0, + "action_required_total": 0, + "canonical_only_total": 5 + } +} diff --git a/backend/models/available-item.model.js b/backend/models/available-item.model.js new file mode 100644 index 0000000..0d4c7d6 --- /dev/null +++ b/backend/models/available-item.model.js @@ -0,0 +1,273 @@ +const pool = require("../db/pool"); + +function normalizeItemName(itemName) { + return String(itemName || "").trim().toLowerCase(); +} + +async function getHouseholdStoreItemRecord(householdId, storeId, itemId) { + const result = await pool.query( + `WITH latest_list_items AS ( + SELECT DISTINCT ON (hl.household_store_item_id) + hl.household_store_item_id, + hl.custom_image, + hl.custom_image_mime_type, + hl.modified_on, + hl.id + FROM household_lists hl + WHERE hl.household_id = $1 + AND hl.store_id = $2 + ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC + ) + SELECT + hsi.id AS item_id, + hsi.name AS item_name, + ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image, + COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, + hic.item_type, + hic.item_group, + hic.zone + FROM household_store_items hsi + LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hsi.household_id + AND hic.store_id = hsi.store_id + AND hic.household_store_item_id = hsi.id + WHERE hsi.household_id = $1 + AND hsi.store_id = $2 + AND hsi.id = $3`, + [householdId, storeId, itemId] + ); + + return result.rows[0] || null; +} + +async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) { + const normalizedName = normalizeItemName(itemName); + const existing = await pool.query( + `SELECT id, name + FROM household_store_items + WHERE household_id = $1 + AND store_id = $2 + AND normalized_name = $3`, + [householdId, storeId, normalizedName] + ); + + if (existing.rowCount > 0) { + return { + itemId: existing.rows[0].id, + itemName: existing.rows[0].name, + }; + } + + const created = await pool.query( + `INSERT INTO household_store_items + (household_id, store_id, name, normalized_name, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + RETURNING id, name`, + [householdId, storeId, normalizedName, normalizedName] + ); + + return { + itemId: created.rows[0].id, + itemName: created.rows[0].name, + }; +} + +exports.listAvailableItems = async (householdId, storeId, query = "") => { + const trimmedQuery = String(query || "").trim(); + const values = [householdId, storeId]; + let filterClause = ""; + + if (trimmedQuery) { + values.push(`%${trimmedQuery}%`); + filterClause = "AND hsi.name ILIKE $3"; + } + + const result = await pool.query( + `WITH latest_list_items AS ( + SELECT DISTINCT ON (hl.household_store_item_id) + hl.household_store_item_id, + hl.custom_image, + hl.custom_image_mime_type, + hl.modified_on, + hl.id + FROM household_lists hl + WHERE hl.household_id = $1 + AND hl.store_id = $2 + ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC + ) + SELECT + hsi.id AS item_id, + hsi.name AS item_name, + ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image, + COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type, + hic.item_type, + hic.item_group, + hic.zone, + ( + hsi.custom_image IS NOT NULL + OR hic.household_store_item_id IS NOT NULL + ) AS has_managed_settings + FROM household_store_items hsi + LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hsi.household_id + AND hic.store_id = hsi.store_id + AND hic.household_store_item_id = hsi.id + WHERE hsi.household_id = $1 + AND hsi.store_id = $2 + ${filterClause} + ORDER BY hsi.name ASC + LIMIT 100`, + values + ); + + return result.rows; +}; + +exports.getAvailableItemById = async (householdId, storeId, itemId) => + getHouseholdStoreItemRecord(householdId, storeId, itemId); + +exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => { + const normalizedName = normalizeItemName(itemName); + const result = await pool.query( + `SELECT + id AS item_id, + name AS item_name, + custom_image, + custom_image_mime_type + FROM household_store_items + WHERE household_id = $1 + AND store_id = $2 + AND normalized_name = $3`, + [householdId, storeId, normalizedName] + ); + + return result.rows[0] || null; +}; + +exports.createAvailableItem = async ( + householdId, + storeId, + itemName, + imageBuffer = null, + mimeType = null +) => { + const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName); + + if (imageBuffer && mimeType) { + await pool.query( + `UPDATE household_store_items + SET custom_image = $1, + custom_image_mime_type = $2, + updated_at = NOW() + WHERE id = $3 + AND household_id = $4 + AND store_id = $5`, + [imageBuffer, mimeType, itemId, householdId, storeId] + ); + } + + return getHouseholdStoreItemRecord(householdId, storeId, itemId); +}; + +exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => { + const { + itemName, + imageBuffer, + mimeType, + removeImage = false, + } = updates; + + const assignments = ["updated_at = NOW()"]; + const values = [householdId, storeId, itemId]; + let parameterIndex = values.length; + + if (itemName !== undefined && String(itemName).trim() !== "") { + const normalizedName = normalizeItemName(itemName); + parameterIndex += 1; + assignments.push(`name = $${parameterIndex}`); + values.push(normalizedName); + + parameterIndex += 1; + assignments.push(`normalized_name = $${parameterIndex}`); + values.push(normalizedName); + } + + if (removeImage) { + assignments.push("custom_image = NULL", "custom_image_mime_type = NULL"); + } else if (imageBuffer && mimeType) { + parameterIndex += 1; + assignments.push(`custom_image = $${parameterIndex}`); + values.push(imageBuffer); + + parameterIndex += 1; + assignments.push(`custom_image_mime_type = $${parameterIndex}`); + values.push(mimeType); + } + + const result = await pool.query( + `UPDATE household_store_items + SET ${assignments.join(", ")} + WHERE household_id = $1 + AND store_id = $2 + AND id = $3 + RETURNING id`, + values + ); + + if (result.rowCount === 0) { + return null; + } + + return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id); +}; + +exports.deleteAvailableItem = async (householdId, storeId, itemId) => { + const result = await pool.query( + `DELETE FROM household_store_items + WHERE household_id = $1 + AND store_id = $2 + AND id = $3`, + [householdId, storeId, itemId] + ); + + return result.rowCount > 0; +}; + +exports.importCurrentListItems = async (householdId, storeId) => { + const result = await pool.query( + `INSERT INTO household_store_items + (household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at) + SELECT DISTINCT ON (hl.household_store_item_id) + hl.household_id, + hl.store_id, + hsi.name, + hsi.normalized_name, + hsi.custom_image, + hsi.custom_image_mime_type, + NOW() + FROM household_lists hl + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + WHERE hl.household_id = $1 + AND hl.store_id = $2 + ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING + RETURNING id`, + [householdId, storeId] + ); + + return result.rowCount; +}; + +exports.hasAvailableItems = async (householdId, storeId) => { + const result = await pool.query( + `SELECT 1 + FROM household_store_items + WHERE household_id = $1 + AND store_id = $2 + LIMIT 1`, + [householdId, storeId] + ); + + return result.rowCount > 0; +}; diff --git a/backend/models/group-invites.model.js b/backend/models/group-invites.model.js new file mode 100644 index 0000000..9a2f888 --- /dev/null +++ b/backend/models/group-invites.model.js @@ -0,0 +1,458 @@ +const pool = require("../db/pool"); + +function getExecutor(client) { + return client || pool; +} + +async function withTransaction(handler) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await handler(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +async function getManageableGroupsForUser(userId, client) { + const result = await getExecutor(client).query( + `SELECT household_id AS group_id + FROM household_members + WHERE user_id = $1 + AND role IN ('owner', 'admin')`, + [userId] + ); + return result.rows; +} + +async function getUserGroupRole(groupId, userId, client) { + const result = await getExecutor(client).query( + `SELECT role + FROM household_members + WHERE household_id = $1 + AND user_id = $2`, + [groupId, userId] + ); + return result.rows[0]?.role || null; +} + +async function getGroupById(groupId, client) { + const result = await getExecutor(client).query( + `SELECT id, name + FROM households + WHERE id = $1`, + [groupId] + ); + return result.rows[0] || null; +} + +async function listInviteLinks(groupId, client) { + const result = await getExecutor(client).query( + `SELECT + id, + group_id, + created_by, + token, + policy, + single_use, + expires_at, + used_at, + revoked_at, + created_at + FROM group_invite_links + WHERE group_id = $1 + ORDER BY created_at DESC`, + [groupId] + ); + return result.rows; +} + +async function createInviteLink( + { groupId, createdBy, token, policy, singleUse, expiresAt }, + client +) { + const result = await getExecutor(client).query( + `INSERT INTO group_invite_links ( + group_id, + created_by, + token, + policy, + single_use, + expires_at + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING + id, + group_id, + created_by, + token, + policy, + single_use, + expires_at, + used_at, + revoked_at, + created_at`, + [groupId, createdBy, token, policy, singleUse, expiresAt] + ); + return result.rows[0]; +} + +async function getInviteLinkById(groupId, linkId, client) { + const result = await getExecutor(client).query( + `SELECT + id, + group_id, + created_by, + token, + policy, + single_use, + expires_at, + used_at, + revoked_at, + created_at + FROM group_invite_links + WHERE group_id = $1 + AND id = $2`, + [groupId, linkId] + ); + return result.rows[0] || null; +} + +async function revokeInviteLink(groupId, linkId, client) { + const result = await getExecutor(client).query( + `UPDATE group_invite_links + SET revoked_at = NOW() + WHERE group_id = $1 + AND id = $2 + RETURNING + id, + group_id, + created_by, + token, + policy, + single_use, + expires_at, + used_at, + revoked_at, + created_at`, + [groupId, linkId] + ); + return result.rows[0] || null; +} + +async function reviveInviteLink(groupId, linkId, expiresAt, client) { + const result = await getExecutor(client).query( + `UPDATE group_invite_links + SET used_at = NULL, + revoked_at = NULL, + expires_at = $3 + WHERE group_id = $1 + AND id = $2 + RETURNING + id, + group_id, + created_by, + token, + policy, + single_use, + expires_at, + used_at, + revoked_at, + created_at`, + [groupId, linkId, expiresAt] + ); + return result.rows[0] || null; +} + +async function deleteInviteLink(groupId, linkId, client) { + const result = await getExecutor(client).query( + `DELETE FROM group_invite_links + WHERE group_id = $1 + AND id = $2 + RETURNING + id, + group_id, + created_by, + token, + policy, + single_use, + expires_at, + used_at, + revoked_at, + created_at`, + [groupId, linkId] + ); + return result.rows[0] || null; +} + +async function getInviteLinkSummaryByToken(token, client, forUpdate = false) { + const result = await getExecutor(client).query( + `SELECT + gil.id, + gil.group_id, + gil.created_by, + gil.token, + gil.policy, + gil.single_use, + gil.expires_at, + gil.used_at, + gil.revoked_at, + gil.created_at, + h.name AS group_name, + gs.join_policy AS current_join_policy + FROM group_invite_links gil + JOIN households h ON h.id = gil.group_id + LEFT JOIN group_settings gs ON gs.group_id = gil.group_id + WHERE gil.token = $1 + ${forUpdate ? "FOR UPDATE OF gil" : ""}`, + [token] + ); + return result.rows[0] || null; +} + +async function isGroupMember(groupId, userId, client) { + const result = await getExecutor(client).query( + `SELECT 1 + FROM household_members + WHERE household_id = $1 + AND user_id = $2`, + [groupId, userId] + ); + return result.rows.length > 0; +} + +async function getPendingJoinRequest(groupId, userId, client) { + const result = await getExecutor(client).query( + `SELECT id, group_id, user_id, status, created_at, updated_at + FROM group_join_requests + WHERE group_id = $1 + AND user_id = $2 + AND status = 'PENDING'`, + [groupId, userId] + ); + return result.rows[0] || null; +} + +async function listPendingJoinRequests(groupId, client) { + const result = await getExecutor(client).query( + `SELECT + gjr.id, + gjr.group_id, + gjr.user_id, + gjr.status, + gjr.created_at, + gjr.updated_at, + u.username, + u.name, + u.display_name + FROM group_join_requests gjr + JOIN users u ON u.id = gjr.user_id + WHERE gjr.group_id = $1 + AND gjr.status = 'PENDING' + ORDER BY gjr.created_at ASC`, + [groupId] + ); + return result.rows; +} + +async function getPendingJoinRequestById(groupId, requestId, client, forUpdate = false) { + const result = await getExecutor(client).query( + `SELECT + gjr.id, + gjr.group_id, + gjr.user_id, + gjr.status, + gjr.decided_by, + gjr.decided_at, + gjr.created_at, + gjr.updated_at, + u.username, + u.name, + u.display_name + FROM group_join_requests gjr + JOIN users u ON u.id = gjr.user_id + WHERE gjr.group_id = $1 + AND gjr.id = $2 + AND gjr.status = 'PENDING' + ${forUpdate ? "FOR UPDATE OF gjr" : ""}`, + [groupId, requestId] + ); + return result.rows[0] || null; +} + +async function createOrTouchPendingJoinRequest(groupId, userId, client) { + const executor = getExecutor(client); + const existing = await executor.query( + `UPDATE group_join_requests + SET updated_at = NOW() + WHERE group_id = $1 + AND user_id = $2 + AND status = 'PENDING' + RETURNING id, group_id, user_id, status, created_at, updated_at`, + [groupId, userId] + ); + if (existing.rows[0]) { + return existing.rows[0]; + } + + try { + const inserted = await executor.query( + `INSERT INTO group_join_requests (group_id, user_id, status) + VALUES ($1, $2, 'PENDING') + RETURNING id, group_id, user_id, status, created_at, updated_at`, + [groupId, userId] + ); + return inserted.rows[0]; + } catch (error) { + if (error.code !== "23505") { + throw error; + } + const fallback = await executor.query( + `SELECT id, group_id, user_id, status, created_at, updated_at + FROM group_join_requests + WHERE group_id = $1 + AND user_id = $2 + AND status = 'PENDING' + LIMIT 1`, + [groupId, userId] + ); + return fallback.rows[0] || null; + } +} + +async function updateJoinRequestDecision(groupId, requestId, status, decidedBy, client) { + const result = await getExecutor(client).query( + `UPDATE group_join_requests + SET status = $3, + decided_by = $4, + decided_at = NOW(), + updated_at = NOW() + WHERE group_id = $1 + AND id = $2 + AND status = 'PENDING' + RETURNING id, group_id, user_id, status, decided_by, decided_at, created_at, updated_at`, + [groupId, requestId, status, decidedBy] + ); + return result.rows[0] || null; +} + +async function addGroupMember(groupId, userId, role = "member", client) { + const result = await getExecutor(client).query( + `INSERT INTO household_members (household_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (household_id, user_id) DO NOTHING + RETURNING id`, + [groupId, userId, role] + ); + return result.rows.length > 0; +} + +async function consumeSingleUseInvite(linkId, client) { + const result = await getExecutor(client).query( + `UPDATE group_invite_links + SET used_at = NOW(), + revoked_at = NOW() + WHERE id = $1 + RETURNING id`, + [linkId] + ); + return result.rows.length > 0; +} + +async function getGroupSettings(groupId, client) { + const result = await getExecutor(client).query( + `SELECT group_id, join_policy + FROM group_settings + WHERE group_id = $1`, + [groupId] + ); + return result.rows[0] || null; +} + +async function upsertGroupSettings(groupId, joinPolicy, client) { + const result = await getExecutor(client).query( + `INSERT INTO group_settings (group_id, join_policy) + VALUES ($1, $2) + ON CONFLICT (group_id) + DO UPDATE SET + join_policy = EXCLUDED.join_policy, + updated_at = NOW() + RETURNING group_id, join_policy`, + [groupId, joinPolicy] + ); + return result.rows[0]; +} + +async function createGroupAuditLog( + { + groupId, + actorUserId, + actorRole, + eventType, + requestId, + ip, + userAgent, + success = true, + errorCode = null, + metadata = {}, + }, + client +) { + const result = await getExecutor(client).query( + `INSERT INTO group_audit_log ( + group_id, + actor_user_id, + actor_role, + event_type, + request_id, + ip, + user_agent, + success, + error_code, + metadata + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) + RETURNING id`, + [ + groupId, + actorUserId, + actorRole, + eventType, + requestId, + ip, + userAgent, + success, + errorCode, + JSON.stringify(metadata || {}), + ] + ); + return result.rows[0]; +} + +module.exports = { + addGroupMember, + createGroupAuditLog, + createInviteLink, + createOrTouchPendingJoinRequest, + consumeSingleUseInvite, + deleteInviteLink, + getGroupById, + getGroupSettings, + getInviteLinkById, + getInviteLinkSummaryByToken, + getManageableGroupsForUser, + getPendingJoinRequestById, + getPendingJoinRequest, + getUserGroupRole, + isGroupMember, + listPendingJoinRequests, + listInviteLinks, + revokeInviteLink, + reviveInviteLink, + updateJoinRequestDecision, + upsertGroupSettings, + withTransaction, +}; diff --git a/backend/models/household.model.js b/backend/models/household.model.js new file mode 100644 index 0000000..528ce89 --- /dev/null +++ b/backend/models/household.model.js @@ -0,0 +1,233 @@ +const pool = require("../db/pool"); + +// Get all households a user belongs to +exports.getUserHouseholds = async (userId) => { + const result = await pool.query( + `SELECT + h.id, + h.name, + h.invite_code, + h.created_at, + hm.role, + hm.joined_at, + (SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count + FROM households h + JOIN household_members hm ON h.id = hm.household_id + WHERE hm.user_id = $1 + ORDER BY hm.joined_at DESC`, + [userId] + ); + return result.rows; +}; + +// Get household by ID (with member check) +exports.getHouseholdById = async (householdId, userId) => { + const result = await pool.query( + `SELECT + h.id, + h.name, + h.invite_code, + h.created_at, + h.created_by, + hm.role as user_role, + (SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count + FROM households h + LEFT JOIN household_members hm ON h.id = hm.household_id AND hm.user_id = $2 + WHERE h.id = $1`, + [householdId, userId] + ); + return result.rows[0]; +}; + +// Create new household +exports.createHousehold = async (name, createdBy) => { + // Generate random 6-digit invite code + const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase(); + + const result = await pool.query( + `INSERT INTO households (name, created_by, invite_code) + VALUES ($1, $2, $3) + RETURNING id, name, invite_code, created_at`, + [name, createdBy, inviteCode] + ); + + // Add creator as admin + await pool.query( + `INSERT INTO household_members (household_id, user_id, role) + VALUES ($1, $2, 'owner')`, + [result.rows[0].id, createdBy] + ); + + return result.rows[0]; +}; + +// Update household +exports.updateHousehold = async (householdId, updates) => { + const { name } = updates; + const result = await pool.query( + `UPDATE households + SET name = COALESCE($1, name) + WHERE id = $2 + RETURNING id, name, invite_code, created_at`, + [name, householdId] + ); + return result.rows[0]; +}; + +// Delete household +exports.deleteHousehold = async (householdId) => { + await pool.query('DELETE FROM households WHERE id = $1', [householdId]); +}; + +// Refresh invite code +exports.refreshInviteCode = async (householdId) => { + const inviteCode = 'H' + Math.random().toString(36).substring(2, 8).toUpperCase(); + const result = await pool.query( + `UPDATE households + SET invite_code = $1, code_expires_at = NULL + WHERE id = $2 + RETURNING id, name, invite_code`, + [inviteCode, householdId] + ); + return result.rows[0]; +}; + +// Join household via invite code +exports.joinHousehold = async (inviteCode, userId) => { + const householdResult = await pool.query( + `SELECT id, name FROM households + WHERE invite_code = $1 + AND (code_expires_at IS NULL OR code_expires_at > NOW())`, + [inviteCode] + ); + + + if (householdResult.rows.length === 0) return null; + + const household = householdResult.rows[0]; + + const existingMember = await pool.query( + `SELECT id FROM household_members + WHERE household_id = $1 AND user_id = $2`, + [household.id, userId] + ); + + if (existingMember.rows.length > 0) return { ...household, alreadyMember: true }; + + + // Add as user role + await pool.query( + `INSERT INTO household_members (household_id, user_id, role) + VALUES ($1, $2, 'member')`, + [household.id, userId] + ); + + return { ...household, alreadyMember: false }; +}; + +// Get household members +exports.getHouseholdMembers = async (householdId) => { + const result = await pool.query( + `SELECT + u.id, + u.username, + u.name, + u.display_name, + hm.role, + hm.joined_at + FROM household_members hm + JOIN users u ON hm.user_id = u.id + WHERE hm.household_id = $1 + ORDER BY + CASE hm.role + WHEN 'owner' THEN 1 + WHEN 'admin' THEN 2 + WHEN 'member' THEN 3 + END, + hm.joined_at ASC`, + [householdId] + ); + return result.rows; +}; + +// Update member role +exports.updateMemberRole = async (householdId, userId, newRole) => { + const result = await pool.query( + `UPDATE household_members + SET role = $1 + WHERE household_id = $2 AND user_id = $3 + RETURNING user_id, role`, + [newRole, householdId, userId] + ); + return result.rows[0]; +}; + +// Transfer household ownership from one member to another atomically. +exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUserId) => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const promoteResult = await client.query( + `UPDATE household_members + SET role = 'owner' + WHERE household_id = $1 AND user_id = $2 + RETURNING user_id, role`, + [householdId, nextOwnerUserId] + ); + + if (promoteResult.rows.length === 0) { + throw new Error("TARGET_MEMBER_NOT_FOUND"); + } + + const demoteResult = await client.query( + `UPDATE household_members + SET role = 'admin' + WHERE household_id = $1 AND user_id = $2 + RETURNING user_id, role`, + [householdId, currentOwnerUserId] + ); + + if (demoteResult.rows.length === 0) { + throw new Error("CURRENT_OWNER_NOT_FOUND"); + } + + await client.query("COMMIT"); + return promoteResult.rows[0]; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +// Remove member from household +exports.removeMember = async (householdId, userId) => { + await pool.query( + `DELETE FROM household_members + WHERE household_id = $1 AND user_id = $2`, + [householdId, userId] + ); +}; + +// Get user's role in household +exports.getUserRole = async (householdId, userId) => { + const result = await pool.query( + `SELECT role FROM household_members + WHERE household_id = $1 AND user_id = $2`, + [householdId, userId] + ); + return result.rows[0]?.role || null; +}; + +// Check if user is household member +exports.isHouseholdMember = async (householdId, userId) => { + const result = await pool.query( + `SELECT 1 FROM household_members + WHERE household_id = $1 AND user_id = $2`, + [householdId, userId] + ); + return result.rows.length > 0; +}; diff --git a/backend/models/list.model.v2.js b/backend/models/list.model.v2.js new file mode 100644 index 0000000..7f513bb --- /dev/null +++ b/backend/models/list.model.v2.js @@ -0,0 +1,387 @@ +const pool = require("../db/pool"); + +function normalizeItemName(itemName) { + return String(itemName || "").trim().toLowerCase(); +} + +async function getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName) { + const result = await pool.query( + `SELECT id, name, normalized_name, custom_image, custom_image_mime_type + FROM household_store_items + WHERE household_id = $1 + AND store_id = $2 + AND normalized_name = $3`, + [householdId, storeId, normalizedName] + ); + + return result.rows[0] || null; +} + +exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => { + const normalizedName = normalizeItemName(itemName); + let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName); + + if (item) { + return item; + } + + const result = await pool.query( + `INSERT INTO household_store_items + (household_id, store_id, name, normalized_name, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + RETURNING id, name, normalized_name, custom_image, custom_image_mime_type`, + [householdId, storeId, normalizedName, normalizedName] + ); + + return result.rows[0]; +}; + +/** + * Get list items for a specific household and store + * @param {number} householdId - Household ID + * @param {number} storeId - Store ID + * @param {boolean} includeHistory - Include purchase history + * @returns {Promise} List of items + */ +exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => { + const result = await pool.query( + `SELECT + hl.id, + hl.household_store_item_id AS item_id, + hl.household_store_item_id, + hsi.name AS item_name, + hl.quantity, + hl.bought, + ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, + COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ${includeHistory ? ` + ( + SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) + FROM ( + SELECT DISTINCT + COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label + FROM household_list_history hlh + JOIN users u ON hlh.added_by = u.id + WHERE hlh.household_list_id = hl.id + ) added_by_labels + ) AS added_by_users, + ` : "NULL AS added_by_users,"} + hl.modified_on AS last_added_on, + hic.item_type, + hic.item_group, + hic.zone + FROM household_lists hl + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hl.household_id + AND hic.store_id = hl.store_id + AND hic.household_store_item_id = hl.household_store_item_id + WHERE hl.household_id = $1 + AND hl.store_id = $2 + AND hl.bought = FALSE + ORDER BY hl.id ASC`, + [householdId, storeId] + ); + return result.rows; +}; + +/** + * Get a specific item from household list by name + * @param {number} householdId - Household ID + * @param {number} storeId - Store ID + * @param {string} itemName - Item name to search for + * @returns {Promise} Item or null + */ +exports.getItemByName = async (householdId, storeId, itemName) => { + const normalizedName = normalizeItemName(itemName); + const result = await pool.query( + `SELECT + hl.id, + hl.household_store_item_id AS item_id, + hl.household_store_item_id, + hsi.name AS item_name, + hl.quantity, + hl.bought, + ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, + COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ( + SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) + FROM ( + SELECT DISTINCT + COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label + FROM household_list_history hlh + JOIN users u ON hlh.added_by = u.id + WHERE hlh.household_list_id = hl.id + ) added_by_labels + ) AS added_by_users, + hl.modified_on AS last_added_on, + hic.item_type, + hic.item_group, + hic.zone + FROM household_lists hl + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + LEFT JOIN household_item_classifications hic + ON hic.household_id = hl.household_id + AND hic.store_id = hl.store_id + AND hic.household_store_item_id = hl.household_store_item_id + WHERE hl.household_id = $1 + AND hl.store_id = $2 + AND hsi.normalized_name = $3`, + [householdId, storeId, normalizedName] + ); + return result.rows[0] || null; +}; + +/** + * Add or update an item in household list + * @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>} + */ +exports.addOrUpdateItem = async ( + householdId, + storeId, + itemName, + quantity, + userId, + imageBuffer = null, + mimeType = null +) => { + const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName); + const listResult = await pool.query( + `SELECT id, bought + FROM household_lists + WHERE household_id = $1 + AND store_id = $2 + AND household_store_item_id = $3`, + [householdId, storeId, householdStoreItem.id] + ); + + if (listResult.rowCount > 0) { + const listId = listResult.rows[0].id; + if (imageBuffer && mimeType) { + await pool.query( + `UPDATE household_lists + SET quantity = $1, + bought = FALSE, + custom_image = $2, + custom_image_mime_type = $3, + modified_on = NOW() + WHERE id = $4`, + [quantity, imageBuffer, mimeType, listId] + ); + } else { + await pool.query( + `UPDATE household_lists + SET quantity = $1, + bought = FALSE, + modified_on = NOW() + WHERE id = $2`, + [quantity, listId] + ); + } + + return { + listId, + itemId: householdStoreItem.id, + householdStoreItemId: householdStoreItem.id, + itemName: householdStoreItem.name, + isNew: false, + }; + } + + const insert = await pool.query( + `INSERT INTO household_lists + (household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id`, + [householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId] + ); + + return { + listId: insert.rows[0].id, + itemId: householdStoreItem.id, + householdStoreItemId: householdStoreItem.id, + itemName: householdStoreItem.name, + isNew: true, + }; +}; + +exports.setBought = async (listId, bought, quantityBought = null) => { + if (bought === false) { + await pool.query( + "UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1", + [listId] + ); + return; + } + + if (quantityBought && quantityBought > 0) { + const item = await pool.query( + "SELECT quantity FROM household_lists WHERE id = $1", + [listId] + ); + + if (!item.rows[0]) return; + + const currentQuantity = item.rows[0].quantity; + const remainingQuantity = currentQuantity - quantityBought; + + if (remainingQuantity <= 0) { + await pool.query( + "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", + [listId] + ); + } else { + await pool.query( + "UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2", + [remainingQuantity, listId] + ); + } + } else { + await pool.query( + "UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1", + [listId] + ); + } +}; + +exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => { + await pool.query( + `INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on) + VALUES ($1, $2, $3, $4, NOW())`, + [listId, householdStoreItemId, quantity, userId] + ); +}; + +exports.getSuggestions = async (query, householdId, storeId) => { + const result = await pool.query( + `SELECT DISTINCT + hsi.name AS item_name, + CASE WHEN hl.id IS NOT NULL AND hl.bought = FALSE THEN 0 ELSE 1 END AS sort_order + FROM household_store_items hsi + LEFT JOIN household_lists hl + ON hl.household_store_item_id = hsi.id + AND hl.household_id = $2 + AND hl.store_id = $3 + WHERE hsi.household_id = $2 + AND hsi.store_id = $3 + AND hsi.name ILIKE $1 + ORDER BY sort_order, hsi.name + LIMIT 10`, + [`%${query}%`, householdId, storeId] + ); + return result.rows; +}; + +exports.getRecentlyBoughtItems = async (householdId, storeId) => { + const result = await pool.query( + `SELECT + hl.id, + hl.household_store_item_id AS item_id, + hl.household_store_item_id, + hsi.name AS item_name, + hl.quantity, + hl.bought, + ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image, + COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type, + ( + SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label) + FROM ( + SELECT DISTINCT + COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label + FROM household_list_history hlh + JOIN users u ON hlh.added_by = u.id + WHERE hlh.household_list_id = hl.id + ) added_by_labels + ) AS added_by_users, + hl.modified_on AS last_added_on + FROM household_lists hl + JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id + WHERE hl.household_id = $1 + AND hl.store_id = $2 + AND hl.bought = TRUE + AND hl.modified_on >= NOW() - INTERVAL '24 hours' + ORDER BY hl.modified_on DESC`, + [householdId, storeId] + ); + return result.rows; +}; + +exports.getClassification = async (householdId, storeId, itemId) => { + const result = await pool.query( + `SELECT item_type, item_group, zone, confidence, source + FROM household_item_classifications + WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`, + [householdId, storeId, itemId] + ); + return result.rows[0] || null; +}; + +exports.upsertClassification = async (householdId, storeId, itemId, classification) => { + const { item_type, item_group, zone, confidence, source } = classification; + + const result = await pool.query( + `INSERT INTO household_item_classifications + (household_id, store_id, household_store_item_id, item_type, item_group, zone, confidence, source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (household_id, store_id, household_store_item_id) + DO UPDATE SET + item_type = EXCLUDED.item_type, + item_group = EXCLUDED.item_group, + zone = EXCLUDED.zone, + confidence = EXCLUDED.confidence, + source = EXCLUDED.source + RETURNING *`, + [householdId, storeId, itemId, item_type, item_group, zone, confidence, source] + ); + return result.rows[0]; +}; + +exports.deleteClassification = async (householdId, storeId, itemId) => { + const result = await pool.query( + `DELETE FROM household_item_classifications + WHERE household_id = $1 + AND store_id = $2 + AND household_store_item_id = $3`, + [householdId, storeId, itemId] + ); + return result.rowCount > 0; +}; + +exports.updateItem = async (listId, itemName, quantity, notes) => { + const updates = []; + const values = [listId]; + let paramCount = 1; + + if (quantity !== undefined) { + paramCount += 1; + updates.push(`quantity = $${paramCount}`); + values.push(quantity); + } + + if (notes !== undefined) { + paramCount += 1; + updates.push(`notes = $${paramCount}`); + values.push(notes); + } + + updates.push("modified_on = NOW()"); + + if (updates.length === 1) { + const result = await pool.query( + "UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *", + [listId] + ); + return result.rows[0]; + } + + const result = await pool.query( + `UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`, + values + ); + + return result.rows[0]; +}; + +exports.deleteItem = async (listId) => { + await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]); +}; diff --git a/backend/models/session.model.js b/backend/models/session.model.js new file mode 100644 index 0000000..4216db6 --- /dev/null +++ b/backend/models/session.model.js @@ -0,0 +1,123 @@ +const crypto = require("crypto"); +const pool = require("../db/pool"); +const { SESSION_TTL_DAYS } = require("../utils/session-cookie"); + +const INSERT_SESSION_SQL = `INSERT INTO sessions (id, user_id, expires_at, user_agent) + VALUES ($1, $2, NOW() + ($3 || ' days')::interval, $4) + RETURNING id, user_id, created_at, expires_at`; +const SELECT_ACTIVE_SESSION_SQL = `SELECT + s.id, + s.user_id, + s.expires_at, + u.username, + u.role + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.id = $1 + AND s.expires_at > NOW()`; + +let ensureSessionsTablePromise = null; + +function generateSessionId() { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID().replace(/-/g, "") + crypto.randomBytes(8).toString("hex"); + } + return crypto.randomBytes(32).toString("hex"); +} + +function isUndefinedTableError(error) { + return error && error.code === "42P01"; +} + +async function ensureSessionsTable() { + if (!ensureSessionsTablePromise) { + ensureSessionsTablePromise = (async () => { + await pool.query(`CREATE TABLE IF NOT EXISTS sessions ( + id VARCHAR(128) PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + user_agent TEXT +);`); + await pool.query( + "CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);" + ); + await pool.query( + "CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);" + ); + })().catch((error) => { + ensureSessionsTablePromise = null; + throw error; + }); + } + + await ensureSessionsTablePromise; +} + +async function insertSession(id, userId, userAgent) { + const result = await pool.query(INSERT_SESSION_SQL, [ + id, + userId, + String(SESSION_TTL_DAYS), + userAgent, + ]); + return result.rows[0]; +} + +exports.createSession = async (userId, userAgent = null) => { + const id = generateSessionId(); + try { + return await insertSession(id, userId, userAgent); + } catch (error) { + if (!isUndefinedTableError(error)) { + throw error; + } + + await ensureSessionsTable(); + return insertSession(id, userId, userAgent); + } +}; + +exports.getActiveSessionWithUser = async (sessionId) => { + let result; + try { + result = await pool.query(SELECT_ACTIVE_SESSION_SQL, [sessionId]); + } catch (error) { + if (isUndefinedTableError(error)) { + return null; + } + throw error; + } + + const session = result.rows[0] || null; + if (!session) return null; + + try { + await pool.query( + `UPDATE sessions + SET last_seen_at = NOW() + WHERE id = $1`, + [sessionId] + ); + } catch (error) { + if (!isUndefinedTableError(error)) { + throw error; + } + } + + return session; +}; + +exports.deleteSession = async (sessionId) => { + try { + await pool.query( + `DELETE FROM sessions WHERE id = $1`, + [sessionId] + ); + } catch (error) { + if (!isUndefinedTableError(error)) { + throw error; + } + } +}; diff --git a/backend/models/store.model.js b/backend/models/store.model.js new file mode 100644 index 0000000..f838d10 --- /dev/null +++ b/backend/models/store.model.js @@ -0,0 +1,143 @@ +const pool = require("../db/pool"); + +// Get all available stores +exports.getAllStores = async () => { + const result = await pool.query( + `SELECT id, name, default_zones, created_at + FROM stores + ORDER BY name ASC` + ); + return result.rows; +}; + +// Get store by ID +exports.getStoreById = async (storeId) => { + const result = await pool.query( + `SELECT id, name, default_zones, created_at + FROM stores + WHERE id = $1`, + [storeId] + ); + return result.rows[0]; +}; + +// Get stores for a specific household +exports.getHouseholdStores = async (householdId) => { + const result = await pool.query( + `SELECT + s.id, + s.name, + s.default_zones, + hs.is_default, + hs.added_at + FROM stores s + JOIN household_stores hs ON s.id = hs.store_id + WHERE hs.household_id = $1 + ORDER BY hs.is_default DESC, s.name ASC`, + [householdId] + ); + return result.rows; +}; + +// Add store to household +exports.addStoreToHousehold = async (householdId, storeId, isDefault = false) => { + // If setting as default, unset other defaults + if (isDefault) { + await pool.query( + `UPDATE household_stores + SET is_default = FALSE + WHERE household_id = $1`, + [householdId] + ); + } + + const result = await pool.query( + `INSERT INTO household_stores (household_id, store_id, is_default) + VALUES ($1, $2, $3) + ON CONFLICT (household_id, store_id) + DO UPDATE SET is_default = $3 + RETURNING household_id, store_id, is_default`, + [householdId, storeId, isDefault] + ); + + return result.rows[0]; +}; + +// Remove store from household +exports.removeStoreFromHousehold = async (householdId, storeId) => { + await pool.query( + `DELETE FROM household_stores + WHERE household_id = $1 AND store_id = $2`, + [householdId, storeId] + ); +}; + +// Set default store for household +exports.setDefaultStore = async (householdId, storeId) => { + // Unset all defaults + await pool.query( + `UPDATE household_stores + SET is_default = FALSE + WHERE household_id = $1`, + [householdId] + ); + + // Set new default + await pool.query( + `UPDATE household_stores + SET is_default = TRUE + WHERE household_id = $1 AND store_id = $2`, + [householdId, storeId] + ); +}; + +// Create new store (system admin only) +exports.createStore = async (name, defaultZones) => { + const result = await pool.query( + `INSERT INTO stores (name, default_zones) + VALUES ($1, $2) + RETURNING id, name, default_zones, created_at`, + [name, JSON.stringify(defaultZones)] + ); + return result.rows[0]; +}; + +// Update store (system admin only) +exports.updateStore = async (storeId, updates) => { + const { name, default_zones } = updates; + const result = await pool.query( + `UPDATE stores + SET + name = COALESCE($1, name), + default_zones = COALESCE($2, default_zones) + WHERE id = $3 + RETURNING id, name, default_zones, created_at`, + [name, default_zones ? JSON.stringify(default_zones) : null, storeId] + ); + return result.rows[0]; +}; + +// Delete store (system admin only, only if not in use) +exports.deleteStore = async (storeId) => { + // Check if store is in use + const usage = await pool.query( + `SELECT COUNT(*) as count FROM household_stores WHERE store_id = $1`, + [storeId] + ); + + if (parseInt(usage.rows[0].count) > 0) { + throw new Error('Cannot delete store that is in use by households'); + } + + await pool.query('DELETE FROM stores WHERE id = $1', [storeId]); +}; + +// Check if household has store +exports.householdHasStore = async (householdId, storeId) => { + const result = await pool.query( + `SELECT 1 FROM household_stores + WHERE household_id = $1 AND store_id = $2`, + [householdId, storeId] + ); + return result.rows.length > 0; +}; diff --git a/backend/models/user.model.js b/backend/models/user.model.js index 706b226..99a4a1c 100644 --- a/backend/models/user.model.js +++ b/backend/models/user.model.js @@ -1,26 +1,24 @@ const pool = require("../db/pool"); exports.ROLES = { - VIEWER: "viewer", - EDITOR: "editor", - ADMIN: "admin", + SYSTEM_ADMIN: "system_admin", + USER: "user", } -exports.findByUsername = async (username) => { - query = `SELECT * FROM users WHERE username = ${username}`; - const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]); - console.log(query); - return result.rows[0]; -}; +exports.findByUsername = async (username) => { + const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]); + return result.rows[0]; +}; -exports.createUser = async (username, hashedPassword, name) => { - const result = await pool.query( - `INSERT INTO users (username, password, name, role) - VALUES ($1, $2, $3, $4)`, - [username, hashedPassword, name, this.ROLES.VIEWER] - ); - return result.rows[0]; -}; +exports.createUser = async (username, hashedPassword, name) => { + const result = await pool.query( + `INSERT INTO users (username, password, name, role) + VALUES ($1, $2, $3, $4) + RETURNING id, username, name, role`, + [username, hashedPassword, name, exports.ROLES.USER] + ); + return result.rows[0]; +}; exports.getAllUsers = async () => { diff --git a/backend/package-lock.json b/backend/package-lock.json index bd00905..6876c87 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,7 +15,6 @@ "sharp": "^0.34.5" }, "devDependencies": { - "cpx": "^1.5.0", "esbuild": "^0.25.5", "nodemon": "^3.1.11", "rimraf": "^6.0.1" @@ -923,148 +922,17 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz", - "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==", - "dev": true, - "dependencies": { - "micromatch": "^2.1.5", - "normalize-path": "^2.0.0" - } - }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, - "node_modules/arr-diff": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", - "integrity": "sha512-dtXTVMkh6VkEEA7OhXnN1Ecb8aAGFdZ1LFxtOCoqj4qkyOJMt7+qs6Ahdy6p/NQCPYsRSXXivhSB/J5E9jmYKA==", - "dev": true, - "dependencies": { - "arr-flatten": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arr-flatten": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", - "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-unique": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", - "integrity": "sha512-G2n5bG5fSUCpnsXz4+8FUkYsGPkNfLn9YvS66U5qbTIXI2Ynnlo4Bi42bWv+omKUCqz+ejzfClwne0alJWJPhg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/assign-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/async-each": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.6.tgz", - "integrity": "sha512-c646jH1avxr+aVpndVMeAfYw7wAa6idufrlN3LPA4PmKS0QEGp6PIC9nwz0WQkkvBGAMEki3pFdtxaF39J9vvg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ] - }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "dev": true, - "bin": { - "atob": "bin/atob.js" - }, - "engines": { - "node": ">= 4.5.0" - } - }, - "node_modules/babel-runtime": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", - "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", - "dev": true, - "dependencies": { - "core-js": "^2.4.0", - "regenerator-runtime": "^0.11.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/base": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", - "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", - "dev": true, - "dependencies": { - "cache-base": "^1.0.1", - "class-utils": "^0.3.5", - "component-emitter": "^1.2.1", - "define-property": "^1.0.0", - "isobject": "^3.0.1", - "mixin-deep": "^1.2.0", - "pascalcase": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/base/node_modules/define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", - "dev": true, - "dependencies": { - "is-descriptor": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/base/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -1073,68 +941,39 @@ "bcrypt": "bin/bcrypt" } }, - "node_modules/binary-extensions": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", - "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "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.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", - "integrity": "sha512-xU7bpz2ytJl1bH9cgIurjpg/n8Gohy9GTw81heDYLJQ4RU60dlyJsa+atVF2pI0yMMvKxI9HkKwjePCj5XI1hw==", - "dev": true, - "dependencies": { - "expand-range": "^1.8.1", - "preserve": "^0.2.0", - "repeat-element": "^1.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1164,35 +1003,6 @@ "node": ">= 0.8" } }, - "node_modules/cache-base": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", - "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", - "dev": true, - "dependencies": { - "collection-visit": "^1.0.0", - "component-emitter": "^1.2.1", - "get-value": "^2.0.6", - "has-value": "^1.0.0", - "isobject": "^3.0.1", - "set-value": "^2.0.0", - "to-object-path": "^0.3.0", - "union-value": "^1.0.0", - "unset-value": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cache-base/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1220,87 +1030,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chokidar": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", - "integrity": "sha512-mk8fAWcRUOxY7btlLtitj3A45jOwSAxH4tOFOoEGbVsl6cL6pPMWUy7dwZ/canfj3QEdP6FHSnf/l1c6/WkzVg==", - "dev": true, - "dependencies": { - "anymatch": "^1.3.0", - "async-each": "^1.0.0", - "glob-parent": "^2.0.0", - "inherits": "^2.0.1", - "is-binary-path": "^1.0.0", - "is-glob": "^2.0.0", - "path-is-absolute": "^1.0.0", - "readdirp": "^2.0.0" - }, - "optionalDependencies": { - "fsevents": "^1.0.0" - } - }, - "node_modules/class-utils": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", - "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", - "dev": true, - "dependencies": { - "arr-union": "^3.1.0", - "define-property": "^0.2.5", - "isobject": "^3.0.0", - "static-extend": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/class-utils/node_modules/define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", - "dev": true, - "dependencies": { - "is-descriptor": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/class-utils/node_modules/is-descriptor": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", - "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/class-utils/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/collection-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", - "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==", - "dev": true, - "dependencies": { - "map-visit": "^1.0.0", - "object-visit": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1319,15 +1048,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1396,29 +1116,6 @@ "node": ">=6.6.0" } }, - "node_modules/copy-descriptor": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", - "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/core-js": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", - "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", - "dev": true, - "hasInstallScript": true - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -1431,28 +1128,6 @@ "node": ">= 0.10" } }, - "node_modules/cpx": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/cpx/-/cpx-1.5.0.tgz", - "integrity": "sha512-jHTjZhsbg9xWgsP2vuNW2jnnzBX+p4T+vNI9Lbjzs1n4KhOfa22bQppiFYLsWQKd8TzmL5aSP/Me3yfsCwXbDA==", - "dev": true, - "dependencies": { - "babel-runtime": "^6.9.2", - "chokidar": "^1.6.0", - "duplexer": "^0.1.1", - "glob": "^7.0.5", - "glob2base": "^0.0.12", - "minimatch": "^3.0.2", - "mkdirp": "^0.5.1", - "resolve": "^1.1.7", - "safe-buffer": "^5.0.1", - "shell-quote": "^1.6.1", - "subarg": "^1.0.0" - }, - "bin": { - "cpx": "bin/index.js" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1468,9 +1143,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": { "ms": "^2.1.3" }, @@ -1483,37 +1158,6 @@ } } }, - "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "dependencies": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/define-property/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1554,12 +1198,6 @@ "node": ">= 0.4" } }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1673,30 +1311,6 @@ "node": ">= 0.6" } }, - "node_modules/expand-brackets": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", - "integrity": "sha512-hxx03P2dJxss6ceIeri9cmYOT4SRs3Zk3afZwWpOsRqLqprhTR8u++SlC+sFGsQr7WGFPdMF7Gjc1njDLDK6UA==", - "dev": true, - "dependencies": { - "is-posix-bracket": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-range": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", - "integrity": "sha512-AFASGfIlnIbkKPQwX1yHaDjFvh/1gyKJODme52V6IORh69uEYgZp0o9C+qsIGNVEiuuhQU0CSSl++Rlegg1qvA==", - "dev": true, - "dependencies": { - "fill-range": "^2.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -1738,75 +1352,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extend-shallow/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extglob": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", - "integrity": "sha512-1FOj1LOwn42TMrruOHGt18HemVnbwAmAak7krWk+wa93KXxGbK+2jpezm+ytJYDaBX0/SPLZFHKM7m+tKobWGg==", - "dev": true, - "dependencies": { - "is-extglob": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "optional": true - }, - "node_modules/filename-regex": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", - "integrity": "sha512-BTCqyBaWBTsauvnHiE8i562+EdJj+oUpkqWp2R1iCoR8f6oo8STRu3of7WJJ0TqWtxN50a5YFpzYK4Jj9esYfQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fill-range": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.4.tgz", - "integrity": "sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==", - "dev": true, - "dependencies": { - "is-number": "^2.1.0", - "isobject": "^2.0.0", - "randomatic": "^3.0.0", - "repeat-element": "^1.1.2", - "repeat-string": "^1.5.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -1823,33 +1368,6 @@ "node": ">= 0.8" } }, - "node_modules/find-index": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz", - "integrity": "sha512-uJ5vWrfBKMcE6y2Z8834dwEZj9mNGxYa3t3I53OwFeuZ8D9oc2E5zcsrkuhX6h4iYrjhiv0T3szQmxlAV9uxDg==", - "dev": true - }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", - "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", - "dev": true, - "dependencies": { - "for-in": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1874,18 +1392,6 @@ "node": ">= 0.6" } }, - "node_modules/fragment-cache": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", - "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==", - "dev": true, - "dependencies": { - "map-cache": "^0.2.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -1894,31 +1400,6 @@ "node": ">= 0.8" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", - "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", - "deprecated": "Upgrade to fsevents v2 to mitigate potential security issues", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "bindings": "^1.5.0", - "nan": "^2.12.1" - }, - "engines": { - "node": ">= 4.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1962,70 +1443,6 @@ "node": ">= 0.4" } }, - "node_modules/get-value": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", - "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-base": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", - "integrity": "sha512-ab1S1g1EbO7YzauaJLkgLp7DZVAqj9M/dvKlTt8DkXA2tiOIcSMrlVI2J1RZyB5iJVccEscjGn+kpOG9788MHA==", - "dev": true, - "dependencies": { - "glob-parent": "^2.0.0", - "is-glob": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/glob-parent": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", - "integrity": "sha512-JDYOvfxio/t42HKdxkAYaCiBN7oYiuxykOxKxdaUW5Qn0zaYN3gRQWolrwdnf0shM9/EP0ebuuTmyoXNr1cC5w==", - "dev": true, - "dependencies": { - "is-glob": "^2.0.0" - } - }, - "node_modules/glob2base": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz", - "integrity": "sha512-ZyqlgowMbfj2NPjxaZZ/EtsXlOch28FRXgMd64vqZWk1bT9+wvSRLYD1om9M7QfQru51zJPAT17qXm4/zd+9QA==", - "dev": true, - "dependencies": { - "find-index": "^0.1.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2037,12 +1454,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -2063,78 +1474,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", - "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==", - "dev": true, - "dependencies": { - "get-value": "^2.0.6", - "has-values": "^1.0.0", - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-value/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-values": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", - "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==", - "dev": true, - "dependencies": { - "is-number": "^3.0.0", - "kind-of": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-values/node_modules/is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-values/node_modules/is-number/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-values/node_modules/kind-of": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", - "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2147,29 +1486,37 @@ } }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "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==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore-by-default": { @@ -2178,17 +1525,6 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2202,115 +1538,6 @@ "node": ">= 0.10" } }, - "node_modules/is-accessor-descriptor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", - "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", - "integrity": "sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==", - "dev": true, - "dependencies": { - "binary-extensions": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-descriptor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", - "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-dotfile": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", - "integrity": "sha512-9YclgOGtN/f8zx0Pr4FQYMdibBiTaH3sn52vjYip4ZSf6C4/6RfTEZ+MR4GvKhCxdPh21Bg42/WL55f6KSnKpg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-equal-shallow": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", - "integrity": "sha512-0EygVC5qPvIyb+gSz7zdD5/AAoS6Qrx1e//6N4yv4oNm30kqvdmG66oZFWVlQHUWe5OjP08FuTw2IdT0EOTcYA==", - "dev": true, - "dependencies": { - "is-primitive": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2320,107 +1547,17 @@ "node": ">=8" } }, - "node_modules/is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==", - "dev": true, - "dependencies": { - "is-extglob": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", - "integrity": "sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-plain-object/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-posix-bracket": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", - "integrity": "sha512-Yu68oeXJ7LeWNmZ3Zov/xg/oDBnBK2RNxwYY1ilNJX+tKKZqgPK+qOn/Gs9jEu66KDY9Netf5XLKNGzas/vPfQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", - "integrity": "sha512-N3w1tFaRfk3UrPfqeRyD+GYDASU3W5VinKhlORy8EWVf/sIdDL9GAcew85XmktCfH+ngG7SRXEVDoO18WMdB/Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/isobject": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", - "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", - "dev": true, - "dependencies": { - "isarray": "1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/jackspeak": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", @@ -2468,26 +1605,14 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, - "node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2532,27 +1657,6 @@ "node": "20 || >=22" } }, - "node_modules/map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/map-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", - "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==", - "dev": true, - "dependencies": { - "object-visit": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2561,12 +1665,6 @@ "node": ">= 0.4" } }, - "node_modules/math-random": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", - "integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==", - "dev": true - }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -2586,30 +1684,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", - "integrity": "sha512-LnU2XFEk9xxSJ6rfgAry/ty5qwUTyHYOBU0g4R6tIw5ljwgGIBmiKhRWLw5NpMOnrgUNcDJ4WMp8rl3sYVHLNA==", - "dev": true, - "dependencies": { - "arr-diff": "^2.0.0", - "array-unique": "^0.2.1", - "braces": "^1.8.2", - "expand-brackets": "^0.1.4", - "extglob": "^0.3.1", - "filename-regex": "^2.0.0", - "is-extglob": "^1.0.0", - "is-glob": "^2.0.1", - "kind-of": "^3.0.2", - "normalize-path": "^2.0.1", - "object.omit": "^2.0.0", - "parse-glob": "^3.0.4", - "regex-cache": "^0.4.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2630,9 +1704,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, "dependencies": { "brace-expansion": "^1.1.7" @@ -2641,14 +1715,6 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -2658,62 +1724,27 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mixin-deep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", - "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", - "dev": true, - "dependencies": { - "for-in": "^1.0.2", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mixin-deep/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "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==", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -2755,62 +1786,6 @@ "node": ">= 0.6" } }, - "node_modules/nan": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", - "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", - "dev": true, - "optional": true - }, - "node_modules/nanomatch": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", - "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", - "dev": true, - "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "fragment-cache": "^0.2.1", - "is-windows": "^1.0.2", - "kind-of": "^6.0.2", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nanomatch/node_modules/arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nanomatch/node_modules/array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nanomatch/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -3021,18 +1996,6 @@ "node": ">=8.0" } }, - "node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3041,45 +2004,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-copy": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", - "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==", - "dev": true, - "dependencies": { - "copy-descriptor": "^0.1.0", - "define-property": "^0.2.5", - "kind-of": "^3.0.3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-copy/node_modules/define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", - "dev": true, - "dependencies": { - "is-descriptor": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-copy/node_modules/is-descriptor": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", - "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3091,61 +2015,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-visit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", - "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==", - "dev": true, - "dependencies": { - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-visit/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.omit": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", - "integrity": "sha512-UiAM5mhmIuKLsOvrL+B0U2d1hXHF3bFYWIuH1LMpuV2EJEHG1Ntz06PgLEHjm6VFd87NpH8rastvPoyv6UW2fA==", - "dev": true, - "dependencies": { - "for-own": "^0.1.4", - "is-extendable": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.pick/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3171,21 +2040,6 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, - "node_modules/parse-glob": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", - "integrity": "sha512-FC5TeK0AwXzq3tUBFtH74naWkPQCEWs4K+xMxWZBlKDWu0bVHXGZa+KKqxKidd7xwhdZ19ZNuF2uO1M/r196HA==", - "dev": true, - "dependencies": { - "glob-base": "^0.3.0", - "is-dotfile": "^1.0.0", - "is-extglob": "^1.0.0", - "is-glob": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3194,24 +2048,6 @@ "node": ">= 0.8" } }, - "node_modules/pascalcase": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", - "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3221,12 +2057,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "node_modules/path-scurry": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", @@ -3244,11 +2074,12 @@ } }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "engines": { - "node": ">=16" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pg": { @@ -3333,9 +2164,9 @@ } }, "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, "engines": { "node": ">=8.6" @@ -3344,15 +2175,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/posix-character-classes": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", - "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -3388,21 +2210,6 @@ "node": ">=0.10.0" } }, - "node_modules/preserve": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", - "integrity": "sha512-s/46sYeylUfHNjI+sA/78FAHlmIuKqI9wNnzEOGehAlUUYeObv5C2mOinXBjyUyWmJ2SfcS2/ydApH4hTF4WXQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3422,9 +2229,9 @@ "dev": true }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dependencies": { "side-channel": "^1.1.0" }, @@ -3435,38 +2242,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/randomatic": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", - "integrity": "sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==", - "dev": true, - "dependencies": { - "is-number": "^4.0.0", - "kind-of": "^6.0.0", - "math-random": "^1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/randomatic/node_modules/is-number": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", - "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/randomatic/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3476,400 +2251,17 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/readdirp": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", - "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.11", - "micromatch": "^3.1.10", - "readable-stream": "^2.0.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/readdirp/node_modules/arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, - "dependencies": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/braces/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/readdirp/node_modules/expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==", - "dev": true, - "dependencies": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/expand-brackets/node_modules/define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", - "dev": true, - "dependencies": { - "is-descriptor": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/expand-brackets/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/expand-brackets/node_modules/is-descriptor": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", - "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/readdirp/node_modules/extglob": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", - "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, - "dependencies": { - "array-unique": "^0.3.2", - "define-property": "^1.0.0", - "expand-brackets": "^2.1.4", - "extend-shallow": "^2.0.1", - "fragment-cache": "^0.2.1", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/extglob/node_modules/define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", - "dev": true, - "dependencies": { - "is-descriptor": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/extglob/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", - "dev": true, - "dependencies": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/fill-range/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/is-number/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", - "dev": true - }, - "node_modules/regex-cache": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", - "integrity": "sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==", - "dev": true, - "dependencies": { - "is-equal-shallow": "^0.1.3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/regex-not": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", - "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", - "dev": true, - "dependencies": { - "extend-shallow": "^3.0.2", - "safe-regex": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", - "dev": true - }, - "node_modules/repeat-element": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", - "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-url": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", - "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", - "deprecated": "https://github.com/lydell/resolve-url#deprecated", - "dev": true - }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true, - "engines": { - "node": ">=0.12" + "node": ">= 0.10" } }, "node_modules/rimraf": { @@ -3891,24 +2283,37 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/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==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/rimraf/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": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/rimraf/node_modules/glob": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", - "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "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": "^4.0.1", - "minimatch": "^10.0.0", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -3924,15 +2329,15 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3972,15 +2377,6 @@ } ] }, - "node_modules/safe-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", - "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", - "dev": true, - "dependencies": { - "ret": "~0.1.10" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4032,33 +2428,6 @@ "node": ">= 18" } }, - "node_modules/set-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", - "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "dev": true, - "dependencies": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.3", - "split-string": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/set-value/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4128,18 +2497,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4232,166 +2589,6 @@ "node": ">=10" } }, - "node_modules/snapdragon": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", - "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", - "dev": true, - "dependencies": { - "base": "^0.11.1", - "debug": "^2.2.0", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "map-cache": "^0.2.2", - "source-map": "^0.5.6", - "source-map-resolve": "^0.5.0", - "use": "^3.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", - "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", - "dev": true, - "dependencies": { - "define-property": "^1.0.0", - "isobject": "^3.0.0", - "snapdragon-util": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon-node/node_modules/define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", - "dev": true, - "dependencies": { - "is-descriptor": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon-node/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon-util": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", - "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", - "dev": true, - "dependencies": { - "kind-of": "^3.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/snapdragon/node_modules/define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", - "dev": true, - "dependencies": { - "is-descriptor": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon/node_modules/is-descriptor": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", - "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/snapdragon/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-resolve": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", - "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", - "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", - "dev": true, - "dependencies": { - "atob": "^2.1.2", - "decode-uri-component": "^0.2.0", - "resolve-url": "^0.2.1", - "source-map-url": "^0.4.0", - "urix": "^0.1.0" - } - }, - "node_modules/source-map-url": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", - "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", - "deprecated": "See https://github.com/lydell/source-map-url#deprecated", - "dev": true - }, - "node_modules/split-string": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", - "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", - "dev": true, - "dependencies": { - "extend-shallow": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -4400,48 +2597,10 @@ "node": ">= 10.x" } }, - "node_modules/static-extend": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", - "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", - "dev": true, - "dependencies": { - "define-property": "^0.2.5", - "object-copy": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/static-extend/node_modules/define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", - "dev": true, - "dependencies": { - "is-descriptor": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/static-extend/node_modules/is-descriptor": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", - "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "engines": { "node": ">= 0.8" } @@ -4563,15 +2722,6 @@ "node": ">=8" } }, - "node_modules/subarg": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", - "integrity": "sha512-RIrIdRY0X1xojthNcVtgT9sjpOGagEUKpZdgBUi054OEPFo282yg+zE+t1Rj3+RqKq2xStL7uUHhY+AjbC4BXg==", - "dev": true, - "dependencies": { - "minimist": "^1.1.0" - } - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -4584,70 +2734,6 @@ "node": ">=4" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/to-object-path": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", - "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", - "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", - "dev": true, - "dependencies": { - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "regex-not": "^1.0.2", - "safe-regex": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex-range": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", - "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", - "dev": true, - "dependencies": { - "is-number": "^3.0.0", - "repeat-string": "^1.6.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex-range/node_modules/is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4695,21 +2781,6 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, - "node_modules/union-value": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", - "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", - "dev": true, - "dependencies": { - "arr-union": "^3.1.0", - "get-value": "^2.0.6", - "is-extendable": "^0.1.1", - "set-value": "^2.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4718,79 +2789,6 @@ "node": ">= 0.8" } }, - "node_modules/unset-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", - "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==", - "dev": true, - "dependencies": { - "has-value": "^0.3.1", - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/unset-value/node_modules/has-value": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", - "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==", - "dev": true, - "dependencies": { - "get-value": "^2.0.3", - "has-values": "^0.1.4", - "isobject": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/unset-value/node_modules/has-value/node_modules/isobject": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", - "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", - "dev": true, - "dependencies": { - "isarray": "1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/unset-value/node_modules/has-values": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", - "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/unset-value/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/urix": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", - "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", - "deprecated": "Please see https://github.com/lydell/urix#deprecated", - "dev": true - }, - "node_modules/use": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", - "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 5ba58c5..5c59f9b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,13 +10,12 @@ "sharp": "^0.34.5" }, "devDependencies": { - "cpx": "^1.5.0", "esbuild": "^0.25.5", "nodemon": "^3.1.11", "rimraf": "^6.0.1" }, "scripts": { - "build": "rimraf dist && node build.js && cpx \"public/**/*\" dist/public", + "build": "rimraf dist && node build.js", "dev": "nodemon server.js" } } diff --git a/backend/public/TEST_SUITE_README.md b/backend/public/TEST_SUITE_README.md new file mode 100644 index 0000000..ab6627a --- /dev/null +++ b/backend/public/TEST_SUITE_README.md @@ -0,0 +1,43 @@ +# API Test Suite + +The test suite has been reorganized into separate files for better maintainability: + +## New Modular Structure (✅ Complete) +- **api-tests.html** - Main HTML file +- **test-config.js** - Global state management +- **test-definitions.js** - All 62 test cases across 8 categories +- **test-runner.js** - Test execution logic +- **test-ui.js** - UI manipulation functions +- **test-styles.css** - All CSS styles + +## How to Use +1. Start the dev server: `docker-compose -f docker-compose.dev.yml up` +2. Navigate to: `http://localhost:5000/test/api-tests.html` +3. Configure credentials (default: admin/admin123) +4. Click "▶ Run All Tests" + +## Features +- ✅ 62 comprehensive tests +- ✅ Collapsible test cards (collapsed by default) +- ✅ Expected field validation with visual indicators +- ✅ Color-coded HTTP status badges +- ✅ Auto-expansion on test run +- ✅ Expand/Collapse all buttons +- ✅ Real-time pass/fail/error states +- ✅ Summary dashboard + +## File Structure +``` +backend/public/ +├── api-tests.html # Main entry point (use this) +├── test-config.js # State management (19 lines) +├── test-definitions.js # Test cases (450+ lines) +├── test-runner.js # Test execution (160+ lines) +├── test-ui.js # UI functions (90+ lines) +└── test-styles.css # All styles (310+ lines) +``` + +## Old File +- **api-test.html** - Original monolithic version (kept for reference) + +Total: ~1030 lines split into 6 clean, modular files diff --git a/backend/public/api-test.html b/backend/public/api-test.html new file mode 100644 index 0000000..55c2f66 --- /dev/null +++ b/backend/public/api-test.html @@ -0,0 +1,1037 @@ + + + + + + + API Test Suite - Grocery List + + + + +
+

🧪 API Test Suite

+

Multi-Household Grocery List API Testing

+ +
+

Configuration

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/backend/public/api-tests.html b/backend/public/api-tests.html new file mode 100644 index 0000000..f554bb7 --- /dev/null +++ b/backend/public/api-tests.html @@ -0,0 +1,63 @@ + + + + + + + API Test Suite - Grocery List + + + + +
+

🧪 API Test Suite

+

Multi-Household Grocery List API Testing

+ +
+

Configuration

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+ + + +
+
+ + + + + + + + \ No newline at end of file diff --git a/backend/public/test-config.js b/backend/public/test-config.js new file mode 100644 index 0000000..9327903 --- /dev/null +++ b/backend/public/test-config.js @@ -0,0 +1,19 @@ +// Global state +let authToken = null; +let householdId = null; +let storeId = null; +let testUserId = null; +let createdHouseholdId = null; +let secondHouseholdId = null; +let inviteCode = null; + +// Reset state +function resetState() { + authToken = null; + householdId = null; + storeId = null; + testUserId = null; + createdHouseholdId = null; + secondHouseholdId = null; + inviteCode = null; +} diff --git a/backend/public/test-definitions.js b/backend/public/test-definitions.js new file mode 100644 index 0000000..d11d922 --- /dev/null +++ b/backend/public/test-definitions.js @@ -0,0 +1,826 @@ +// Test definitions - 108 tests across 14 categories +const tests = [ + { + category: "Authentication", + tests: [ + { + name: "Login with valid credentials", + method: "POST", + endpoint: "/auth/login", + auth: false, + body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }), + expect: (res) => res.token && res.role, + expectedFields: ['token', 'username', 'role'], + onSuccess: (res) => { authToken = res.token; } + }, + { + name: "Login with invalid credentials", + method: "POST", + endpoint: "/auth/login", + auth: false, + body: { username: "wronguser", password: "wrongpass" }, + expectFail: true, + expect: (res, status) => status === 401, + expectedFields: ['message'] + }, + { + name: "Access protected route without token", + method: "GET", + endpoint: "/households", + auth: false, + expectFail: true, + expect: (res, status) => status === 401 + } + ] + }, + { + category: "Households", + tests: [ + { + name: "Get user's households", + method: "GET", + endpoint: "/households", + auth: true, + expect: (res) => Array.isArray(res), + onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; } + }, + { + name: "Create new household", + method: "POST", + endpoint: "/households", + auth: true, + body: { name: `Test Household ${Date.now()}` }, + expect: (res) => res.household && res.household.invite_code, + expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code'] + }, + { + name: "Get household details", + method: "GET", + endpoint: () => `/households/${householdId}`, + auth: true, + skip: () => !householdId, + expect: (res) => res.id === householdId, + expectedFields: ['id', 'name', 'invite_code', 'created_at'] + }, + { + name: "Update household name", + method: "PATCH", + endpoint: () => `/households/${householdId}`, + auth: true, + skip: () => !householdId, + body: { name: `Updated Household ${Date.now()}` }, + expect: (res) => res.household, + expectedFields: ['message', 'household', 'household.id', 'household.name'] + }, + { + name: "Refresh invite code", + method: "POST", + endpoint: () => `/households/${householdId}/invite/refresh`, + auth: true, + skip: () => !householdId, + expect: (res) => res.household && res.household.invite_code, + expectedFields: ['message', 'household', 'household.invite_code'] + }, + { + name: "Join household with invalid code", + method: "POST", + endpoint: "/households/join/INVALID123", + auth: true, + expectFail: true, + expect: (res, status) => status === 404 + }, + { + name: "Create household with empty name (validation)", + method: "POST", + endpoint: "/households", + auth: true, + body: { name: "" }, + expectFail: true, + expect: (res, status) => status === 400, + expectedFields: ['error'] + } + ] + }, + { + category: "Members", + tests: [ + { + name: "Get household members", + method: "GET", + endpoint: () => `/households/${householdId}/members`, + auth: true, + skip: () => !householdId, + expect: (res) => Array.isArray(res) && res.length > 0, + onSuccess: (res) => { testUserId = res[0].user_id; } + }, + { + name: "Update member role (non-admin attempting)", + method: "PATCH", + endpoint: () => `/households/${householdId}/members/${testUserId}/role`, + auth: true, + skip: () => !householdId || !testUserId, + body: { role: "user" }, + expectFail: true, + expect: (res, status) => status === 400 || status === 403 + } + ] + }, + { + category: "Stores", + tests: [ + { + name: "Get all stores catalog", + method: "GET", + endpoint: "/stores", + auth: true, + expect: (res) => Array.isArray(res), + onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; } + }, + { + name: "Get household stores", + method: "GET", + endpoint: () => `/stores/household/${householdId}`, + auth: true, + skip: () => !householdId, + expect: (res) => Array.isArray(res) + }, + { + name: "Add store to household", + method: "POST", + endpoint: () => `/stores/household/${householdId}`, + auth: true, + skip: () => !householdId || !storeId, + body: () => ({ storeId: storeId, isDefault: true }), + expect: (res) => res.store, + expectedFields: ['message', 'store', 'store.id', 'store.name'] + }, + { + name: "Set default store", + method: "PATCH", + endpoint: () => `/stores/household/${householdId}/${storeId}/default`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => res.message + }, + { + name: "Add invalid store to household", + method: "POST", + endpoint: () => `/stores/household/${householdId}`, + auth: true, + skip: () => !householdId, + body: { storeId: 99999 }, + expectFail: true, + expect: (res, status) => status === 404 + } + ] + }, + { + category: "Advanced Household Tests", + tests: [ + { + name: "Create household for complex workflows", + method: "POST", + endpoint: "/households", + auth: true, + body: { name: `Workflow Test ${Date.now()}` }, + expect: (res) => res.household && res.household.id, + onSuccess: (res) => { + createdHouseholdId = res.household.id; + inviteCode = res.household.invite_code; + } + }, + { + name: "Verify invite code format (7 chars)", + method: "GET", + endpoint: () => `/households/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H') + }, + { + name: "Get household with no stores added yet", + method: "GET", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => Array.isArray(res) && res.length === 0 + }, + { + name: "Update household with very long name (validation)", + method: "PATCH", + endpoint: () => `/households/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + body: { name: "A".repeat(101) }, + expectFail: true, + expect: (res, status) => status === 400 + }, + { + name: "Refresh invite code changes value", + method: "POST", + endpoint: () => `/households/${createdHouseholdId}/invite/refresh`, + auth: true, + skip: () => !createdHouseholdId || !inviteCode, + expect: (res) => res.household && res.household.invite_code !== inviteCode, + onSuccess: (res) => { inviteCode = res.household.invite_code; } + }, + { + name: "Join same household twice (idempotent)", + method: "POST", + endpoint: () => `/households/join/${inviteCode}`, + auth: true, + skip: () => !inviteCode, + expect: (res, status) => status === 200 && res.message.includes("already a member") + }, + { + name: "Get non-existent household", + method: "GET", + endpoint: "/households/99999", + auth: true, + expectFail: true, + expect: (res, status) => status === 404 + }, + { + name: "Update non-existent household", + method: "PATCH", + endpoint: "/households/99999", + auth: true, + body: { name: "Test" }, + expectFail: true, + expect: (res, status) => status === 403 || status === 404 + } + ] + }, + { + category: "Member Management Edge Cases", + tests: [ + { + name: "Get members for created household", + method: "GET", + endpoint: () => `/households/${createdHouseholdId}/members`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin' + }, + { + name: "Update own role (should fail)", + method: "PATCH", + endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`, + auth: true, + skip: () => !createdHouseholdId || !testUserId, + body: { role: "user" }, + expectFail: true, + expect: (res, status) => status === 400 && res.error && res.error.includes("own role") + }, + { + name: "Update role with invalid value", + method: "PATCH", + endpoint: () => `/households/${createdHouseholdId}/members/1/role`, + auth: true, + skip: () => !createdHouseholdId, + body: { role: "superadmin" }, + expectFail: true, + expect: (res, status) => status === 400 + }, + { + name: "Remove non-existent member", + method: "DELETE", + endpoint: () => `/households/${createdHouseholdId}/members/99999`, + auth: true, + skip: () => !createdHouseholdId, + expectFail: true, + expect: (res, status) => status === 404 || status === 500 + } + ] + }, + { + category: "Store Management Advanced", + tests: [ + { + name: "Add multiple stores to household", + method: "POST", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + body: () => ({ storeId: storeId, isDefault: false }), + expect: (res) => res.store + }, + { + name: "Add same store twice (duplicate check)", + method: "POST", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + body: () => ({ storeId: storeId, isDefault: false }), + expectFail: true, + expect: (res, status) => status === 400 || status === 409 || status === 500 + }, + { + name: "Set default store for household", + method: "PATCH", + endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + expect: (res) => res.message + }, + { + name: "Verify default store is first in list", + method: "GET", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true + }, + { + name: "Set non-existent store as default", + method: "PATCH", + endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`, + auth: true, + skip: () => !createdHouseholdId, + expectFail: true, + expect: (res, status) => status === 404 || status === 500 + }, + { + name: "Remove store from household", + method: "DELETE", + endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + expect: (res) => res.message + }, + { + name: "Verify store removed from household", + method: "GET", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => Array.isArray(res) && res.length === 0 + } + ] + }, + { + category: "Data Integrity & Cleanup", + tests: [ + { + name: "Create second household for testing", + method: "POST", + endpoint: "/households", + auth: true, + body: { name: `Second Test ${Date.now()}` }, + expect: (res) => res.household && res.household.id, + onSuccess: (res) => { secondHouseholdId = res.household.id; } + }, + { + name: "Verify user belongs to multiple households", + method: "GET", + endpoint: "/households", + auth: true, + expect: (res) => Array.isArray(res) && res.length >= 3 + }, + { + name: "Delete created test household", + method: "DELETE", + endpoint: () => `/households/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => res.message + }, + { + name: "Verify deleted household is gone", + method: "GET", + endpoint: () => `/households/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expectFail: true, + expect: (res, status) => status === 404 || status === 403 + }, + { + name: "Delete second test household", + method: "DELETE", + endpoint: () => `/households/${secondHouseholdId}`, + auth: true, + skip: () => !secondHouseholdId, + expect: (res) => res.message + }, + { + name: "Verify households list updated", + method: "GET", + endpoint: "/households", + auth: true, + expect: (res) => Array.isArray(res) + } + ] + }, + { + category: "List Operations", + tests: [ + { + name: "Get grocery list for household+store", + method: "GET", + endpoint: () => `/households/${householdId}/stores/${storeId}/list`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => Array.isArray(res), + expectedFields: ['items'] + }, + { + name: "Add item to list", + method: "POST", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test API Item", + quantity: "2 units" + }, + expect: (res) => res.item, + expectedFields: ['item', 'item.id', 'item.item_name', 'item.quantity'] + }, + { + name: "Add duplicate item (should update quantity)", + method: "POST", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test API Item", + quantity: "3 units" + }, + expect: (res) => res.item && res.item.quantity === "3 units" + }, + { + name: "Mark item as bought", + method: "PATCH", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test API Item", + bought: true + }, + expect: (res) => res.message, + expectedFields: ['message'] + }, + { + name: "Unmark item (set bought to false)", + method: "PATCH", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test API Item", + bought: false + }, + expect: (res) => res.message + }, + { + name: "Update item details", + method: "PUT", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test API Item", + quantity: "5 units", + notes: "Updated via API test" + }, + expect: (res) => res.item, + expectedFields: ['item', 'item.quantity', 'item.notes'] + }, + { + name: "Get suggestions based on history", + method: "GET", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/suggestions?query=test`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => Array.isArray(res) + }, + { + name: "Get recently bought items", + method: "GET", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => Array.isArray(res) + }, + { + name: "Delete item from list", + method: "DELETE", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test API Item" + }, + expect: (res) => res.message + }, + { + name: "Try to add item with empty name", + method: "POST", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "", + quantity: "1" + }, + expectFail: true, + expect: (res, status) => status === 400 + } + ] + }, + { + category: "Item Classifications", + tests: [ + { + name: "Get item classification", + method: "GET", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Milk`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => res.classification !== undefined, + expectedFields: ['classification'] + }, + { + name: "Set item classification", + method: "POST", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test Classified Item", + classification: "dairy" + }, + expect: (res) => res.message || res.classification + }, + { + name: "Update item classification", + method: "POST", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test Classified Item", + classification: "produce" + }, + expect: (res) => res.message || res.classification + }, + { + name: "Verify classification persists", + method: "GET", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification?item_name=Test Classified Item`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => res.classification === "produce" + } + ] + }, + { + category: "Account Management", + tests: [ + { + name: "Get current user profile", + method: "GET", + endpoint: "/users/me", + auth: true, + expect: (res) => res.username, + expectedFields: ['id', 'username', 'name', 'display_name', 'role'], + onSuccess: (res) => { testUserId = res.id; } + }, + { + name: "Update display name", + method: "PATCH", + endpoint: "/users/me/display-name", + auth: true, + body: { + display_name: "Test Display Name" + }, + expect: (res) => res.message, + expectedFields: ['message'] + }, + { + name: "Verify display name updated", + method: "GET", + endpoint: "/users/me", + auth: true, + expect: (res) => res.display_name === "Test Display Name" + }, + { + name: "Clear display name (set to null)", + method: "PATCH", + endpoint: "/users/me/display-name", + auth: true, + body: { + display_name: null + }, + expect: (res) => res.message + }, + { + name: "Update password", + method: "PATCH", + endpoint: "/users/me/password", + auth: true, + body: () => ({ + currentPassword: document.getElementById('password').value, + newPassword: document.getElementById('password').value + }), + expect: (res) => res.message + }, + { + name: "Try to update password with wrong current password", + method: "PATCH", + endpoint: "/users/me/password", + auth: true, + body: { + currentPassword: "wrongpassword", + newPassword: "newpass123" + }, + expectFail: true, + expect: (res, status) => status === 401 + } + ] + }, + { + category: "Config Endpoints", + tests: [ + { + name: "Get classifications list", + method: "GET", + endpoint: "/config/classifications", + auth: false, + expect: (res) => Array.isArray(res), + expectedFields: ['[0].value', '[0].label', '[0].color'] + }, + { + name: "Get system config", + method: "GET", + endpoint: "/config", + auth: false, + expect: (res) => res.classifications, + expectedFields: ['classifications'] + } + ] + }, + { + category: "Advanced List Scenarios", + tests: [ + { + name: "Add multiple items rapidly", + method: "POST", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Rapid Test Item 1", + quantity: "1" + }, + expect: (res) => res.item + }, + { + name: "Add second rapid item", + method: "POST", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/add`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Rapid Test Item 2", + quantity: "1" + }, + expect: (res) => res.item + }, + { + name: "Verify list contains both items", + method: "GET", + endpoint: () => `/households/${householdId}/stores/${storeId}/list`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => res.items && res.items.length >= 2 + }, + { + name: "Mark both items as bought", + method: "PATCH", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Rapid Test Item 1", + bought: true + }, + expect: (res) => res.message + }, + { + name: "Mark second item as bought", + method: "PATCH", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Rapid Test Item 2", + bought: true + }, + expect: (res) => res.message + }, + { + name: "Verify recent items includes bought items", + method: "GET", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/recent`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => Array.isArray(res) && res.length > 0 + }, + { + name: "Delete first rapid test item", + method: "DELETE", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Rapid Test Item 1" + }, + expect: (res) => res.message + }, + { + name: "Delete second rapid test item", + method: "DELETE", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Rapid Test Item 2" + }, + expect: (res) => res.message + } + ] + }, + { + category: "Edge Cases & Error Handling", + tests: [ + { + name: "Access non-existent household", + method: "GET", + endpoint: "/households/99999", + auth: true, + expectFail: true, + expect: (res, status) => status === 403 || status === 404 + }, + { + name: "Access non-existent store in household", + method: "GET", + endpoint: () => `/households/${householdId}/stores/99999/list`, + auth: true, + skip: () => !householdId, + expectFail: true, + expect: (res, status) => status === 403 || status === 404 + }, + { + name: "Try to update non-existent item", + method: "PUT", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Non Existent Item 999", + quantity: "1" + }, + expectFail: true, + expect: (res, status) => status === 404 + }, + { + name: "Try to delete non-existent item", + method: "DELETE", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/item`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Non Existent Item 999" + }, + expectFail: true, + expect: (res, status) => status === 404 + }, + { + name: "Invalid classification value", + method: "POST", + endpoint: () => `/households/${householdId}/stores/${storeId}/list/classification`, + auth: true, + skip: () => !householdId || !storeId, + body: { + item_name: "Test Item", + classification: "invalid_category_xyz" + }, + expectFail: true, + expect: (res, status) => status === 400 + }, + { + name: "Empty household name on creation", + method: "POST", + endpoint: "/households", + auth: true, + body: { + name: "" + }, + expectFail: true, + expect: (res, status) => status === 400 + } + ] + } +]; diff --git a/backend/public/test-runner.js b/backend/public/test-runner.js new file mode 100644 index 0000000..58ec72f --- /dev/null +++ b/backend/public/test-runner.js @@ -0,0 +1,147 @@ +async function makeRequest(test) { + const apiUrl = document.getElementById('apiUrl').value; + const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint; + const url = `${apiUrl}${endpoint}`; + + const options = { + method: test.method, + headers: { + 'Content-Type': 'application/json', + } + }; + + if (test.auth && authToken) { + options.headers['Authorization'] = `Bearer ${authToken}`; + } + + if (test.body) { + options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body); + } + + const response = await fetch(url, options); + const data = await response.json().catch(() => ({})); + + return { data, status: response.status }; +} + +async function runTest(categoryIdx, testIdx) { + const test = tests[categoryIdx].tests[testIdx]; + const testId = `test-${categoryIdx}-${testIdx}`; + const testEl = document.getElementById(testId); + const contentEl = document.getElementById(`${testId}-content`); + const toggleEl = document.getElementById(`${testId}-toggle`); + const resultEl = testEl.querySelector('.test-result'); + + if (test.skip && test.skip()) { + testEl.querySelector('.test-status').textContent = 'SKIPPED'; + testEl.querySelector('.test-status').className = 'test-status pending'; + resultEl.style.display = 'block'; + resultEl.className = 'test-result'; + resultEl.innerHTML = '⚠️ Prerequisites not met'; + return 'skip'; + } + + testEl.className = 'test-case running'; + testEl.querySelector('.test-status').textContent = 'RUNNING'; + testEl.querySelector('.test-status').className = 'test-status running'; + resultEl.style.display = 'none'; + + try { + const { data, status } = await makeRequest(test); + + const expectFail = test.expectFail || false; + const passed = test.expect(data, status); + + const success = expectFail ? !passed || status >= 400 : passed; + + testEl.className = success ? 'test-case pass' : 'test-case fail'; + testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL'; + testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`; + + // Determine status code class + let statusClass = 'status-5xx'; + if (status >= 200 && status < 300) statusClass = 'status-2xx'; + else if (status >= 300 && status < 400) statusClass = 'status-3xx'; + else if (status >= 400 && status < 500) statusClass = 'status-4xx'; + + // Check expected fields if defined + let expectedFieldsHTML = ''; + if (test.expectedFields) { + const fieldChecks = test.expectedFields.map(field => { + const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined; + const icon = exists ? '✓' : '✗'; + const className = exists ? 'pass' : 'fail'; + return `
${icon} ${field}
`; + }).join(''); + + expectedFieldsHTML = ` +
+
Expected Fields:
+ ${fieldChecks} +
+ `; + } + + resultEl.style.display = 'block'; + resultEl.className = 'test-result'; + resultEl.innerHTML = ` +
+ HTTP ${status} + ${success ? '✓ Test passed' : '✗ Test failed'} +
+ ${expectedFieldsHTML} +
Response:
+
${JSON.stringify(data, null, 2)}
+ `; + + if (success && test.onSuccess) { + test.onSuccess(data); + } + + return success ? 'pass' : 'fail'; + } catch (error) { + testEl.className = 'test-case fail'; + testEl.querySelector('.test-status').textContent = 'ERROR'; + testEl.querySelector('.test-status').className = 'test-status fail'; + + resultEl.style.display = 'block'; + resultEl.className = 'test-error'; + resultEl.innerHTML = ` +
❌ Network/Request Error
+
${error.message}
+ ${error.stack ? `
${error.stack}
` : ''} + `; + return 'fail'; + } +} + +async function runAllTests(event) { + resetState(); + + const button = event.target; + button.disabled = true; + button.textContent = '⏳ Running Tests...'; + + let totalTests = 0; + let passedTests = 0; + let failedTests = 0; + + for (let i = 0; i < tests.length; i++) { + for (let j = 0; j < tests[i].tests.length; j++) { + const result = await runTest(i, j); + if (result !== 'skip') { + totalTests++; + if (result === 'pass') passedTests++; + if (result === 'fail') failedTests++; + } + } + } + + document.getElementById('summary').style.display = 'flex'; + document.getElementById('totalTests').textContent = totalTests; + document.getElementById('passedTests').textContent = passedTests; + document.getElementById('failedTests').textContent = failedTests; + + button.disabled = false; + button.textContent = '▶ Run All Tests'; +} diff --git a/backend/public/test-script.js b/backend/public/test-script.js new file mode 100644 index 0000000..b108a58 --- /dev/null +++ b/backend/public/test-script.js @@ -0,0 +1,666 @@ + let authToken = null; + let householdId = null; + let storeId = null; + let testUserId = null; + let createdHouseholdId = null; + let secondHouseholdId = null; + let inviteCode = null; + + const tests = [ + { + category: "Authentication", + tests: [ + { + name: "Login with valid credentials", + method: "POST", + endpoint: "/auth/login", + auth: false, + body: () => ({ username: document.getElementById('username').value, password: document.getElementById('password').value }), + expect: (res) => res.token && res.role, + expectedFields: ['token', 'username', 'role'], + onSuccess: (res) => { authToken = res.token; } + }, + { + name: "Login with invalid credentials", + method: "POST", + endpoint: "/auth/login", + auth: false, + body: { username: "wronguser", password: "wrongpass" }, + expectFail: true, + expect: (res, status) => status === 401, + expectedFields: ['message'] + }, + { + name: "Access protected route without token", + method: "GET", + endpoint: "/households", + auth: false, + expectFail: true, + expect: (res, status) => status === 401 + } + ] + }, + { + category: "Households", + tests: [ + { + name: "Get user's households", + method: "GET", + endpoint: "/households", + auth: true, + expect: (res) => Array.isArray(res), + onSuccess: (res) => { if (res.length > 0) householdId = res[0].id; } + }, + { + name: "Create new household", + method: "POST", + endpoint: "/households", + auth: true, + body: { name: `Test Household ${Date.now()}` }, + expect: (res) => res.household && res.household.invite_code, + expectedFields: ['message', 'household', 'household.id', 'household.name', 'household.invite_code'] + }, + { + name: "Get household details", + method: "GET", + endpoint: () => `/households/${householdId}`, + auth: true, + skip: () => !householdId, + expect: (res) => res.id === householdId, + expectedFields: ['id', 'name', 'invite_code', 'created_at'] + }, + { + name: "Update household name", + method: "PATCH", + endpoint: () => `/households/${householdId}`, + auth: true, + skip: () => !householdId, + body: { name: `Updated Household ${Date.now()}` }, + expect: (res) => res.household, + expectedFields: ['message', 'household', 'household.id', 'household.name'] + }, + { + name: "Refresh invite code", + method: "POST", + endpoint: () => `/households/${householdId}/invite/refresh`, + auth: true, + skip: () => !householdId, + expect: (res) => res.household && res.household.invite_code, + expectedFields: ['message', 'household', 'household.invite_code'] + }, + { + name: "Join household with invalid code", + method: "POST", + endpoint: "/households/join/INVALID123", + auth: true, + expectFail: true, + expect: (res, status) => status === 404 + }, + { + name: "Create household with empty name (validation)", + method: "POST", + endpoint: "/households", + auth: true, + body: { name: "" }, + expectFail: true, + expect: (res, status) => status === 400, + expectedFields: ['error'] + } + ] + }, + { + category: "Members", + tests: [ + { + name: "Get household members", + method: "GET", + endpoint: () => `/households/${householdId}/members`, + auth: true, + skip: () => !householdId, + expect: (res) => Array.isArray(res) && res.length > 0, + onSuccess: (res) => { testUserId = res[0].user_id; } + }, + { + name: "Update member role (non-admin attempting)", + method: "PATCH", + endpoint: () => `/households/${householdId}/members/${testUserId}/role`, + auth: true, + skip: () => !householdId || !testUserId, + body: { role: "user" }, + expectFail: true, + expect: (res, status) => status === 400 || status === 403 + } + ] + }, + { + category: "Stores", + tests: [ + { + name: "Get all stores catalog", + method: "GET", + endpoint: "/stores", + auth: true, + expect: (res) => Array.isArray(res), + onSuccess: (res) => { if (res.length > 0) storeId = res[0].id; } + }, + { + name: "Get household stores", + method: "GET", + endpoint: () => `/stores/household/${householdId}`, + auth: true, + skip: () => !householdId, + expect: (res) => Array.isArray(res) + }, + { + name: "Add store to household", + method: "POST", + endpoint: () => `/stores/household/${householdId}`, + auth: true, + skip: () => !householdId || !storeId, + body: () => ({ storeId: storeId, isDefault: true }), + expect: (res) => res.store, + expectedFields: ['message', 'store', 'store.id', 'store.name'] + }, + { + name: "Set default store", + method: "PATCH", + endpoint: () => `/stores/household/${householdId}/${storeId}/default`, + auth: true, + skip: () => !householdId || !storeId, + expect: (res) => res.message + }, + { + name: "Add invalid store to household", + method: "POST", + endpoint: () => `/stores/household/${householdId}`, + auth: true, + skip: () => !householdId, + body: { storeId: 99999 }, + expectFail: true, + expect: (res, status) => status === 404 + } + ] + }, + { + category: "Advanced Household Tests", + tests: [ + { + name: "Create household for complex workflows", + method: "POST", + endpoint: "/households", + auth: true, + body: { name: `Workflow Test ${Date.now()}` }, + expect: (res) => res.household && res.household.id, + onSuccess: (res) => { + createdHouseholdId = res.household.id; + inviteCode = res.household.invite_code; + } + }, + { + name: "Verify invite code format (7 chars)", + method: "GET", + endpoint: () => `/households/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => res.invite_code && res.invite_code.length === 7 && res.invite_code.startsWith('H') + }, + { + name: "Get household with no stores added yet", + method: "GET", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => Array.isArray(res) && res.length === 0 + }, + { + name: "Update household with very long name (validation)", + method: "PATCH", + endpoint: () => `/households/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + body: { name: "A".repeat(101) }, + expectFail: true, + expect: (res, status) => status === 400 + }, + { + name: "Refresh invite code changes value", + method: "POST", + endpoint: () => `/households/${createdHouseholdId}/invite/refresh`, + auth: true, + skip: () => !createdHouseholdId || !inviteCode, + expect: (res) => res.household && res.household.invite_code !== inviteCode, + onSuccess: (res) => { inviteCode = res.household.invite_code; } + }, + { + name: "Join same household twice (idempotent)", + method: "POST", + endpoint: () => `/households/join/${inviteCode}`, + auth: true, + skip: () => !inviteCode, + expect: (res, status) => status === 200 && res.message.includes("already a member") + }, + { + name: "Get non-existent household", + method: "GET", + endpoint: "/households/99999", + auth: true, + expectFail: true, + expect: (res, status) => status === 404 + }, + { + name: "Update non-existent household", + method: "PATCH", + endpoint: "/households/99999", + auth: true, + body: { name: "Test" }, + expectFail: true, + expect: (res, status) => status === 403 || status === 404 + } + ] + }, + { + category: "Member Management Edge Cases", + tests: [ + { + name: "Get members for created household", + method: "GET", + endpoint: () => `/households/${createdHouseholdId}/members`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => Array.isArray(res) && res.length >= 1 && res[0].role === 'admin' + }, + { + name: "Update own role (should fail)", + method: "PATCH", + endpoint: () => `/households/${createdHouseholdId}/members/${testUserId}/role`, + auth: true, + skip: () => !createdHouseholdId || !testUserId, + body: { role: "user" }, + expectFail: true, + expect: (res, status) => status === 400 && res.error && res.error.includes("own role") + }, + { + name: "Update role with invalid value", + method: "PATCH", + endpoint: () => `/households/${createdHouseholdId}/members/1/role`, + auth: true, + skip: () => !createdHouseholdId, + body: { role: "superadmin" }, + expectFail: true, + expect: (res, status) => status === 400 + }, + { + name: "Remove non-existent member", + method: "DELETE", + endpoint: () => `/households/${createdHouseholdId}/members/99999`, + auth: true, + skip: () => !createdHouseholdId, + expectFail: true, + expect: (res, status) => status === 404 || status === 500 + } + ] + }, + { + category: "Store Management Advanced", + tests: [ + { + name: "Add multiple stores to household", + method: "POST", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + body: () => ({ storeId: storeId, isDefault: false }), + expect: (res) => res.store + }, + { + name: "Add same store twice (duplicate check)", + method: "POST", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + body: () => ({ storeId: storeId, isDefault: false }), + expectFail: true, + expect: (res, status) => status === 400 || status === 409 || status === 500 + }, + { + name: "Set default store for household", + method: "PATCH", + endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}/default`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + expect: (res) => res.message + }, + { + name: "Verify default store is first in list", + method: "GET", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + expect: (res) => Array.isArray(res) && res.length > 0 && res[0].is_default === true + }, + { + name: "Set non-existent store as default", + method: "PATCH", + endpoint: () => `/stores/household/${createdHouseholdId}/99999/default`, + auth: true, + skip: () => !createdHouseholdId, + expectFail: true, + expect: (res, status) => status === 404 || status === 500 + }, + { + name: "Remove store from household", + method: "DELETE", + endpoint: () => `/stores/household/${createdHouseholdId}/${storeId}`, + auth: true, + skip: () => !createdHouseholdId || !storeId, + expect: (res) => res.message + }, + { + name: "Verify store removed from household", + method: "GET", + endpoint: () => `/stores/household/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => Array.isArray(res) && res.length === 0 + } + ] + }, + { + category: "Data Integrity & Cleanup", + tests: [ + { + name: "Create second household for testing", + method: "POST", + endpoint: "/households", + auth: true, + body: { name: `Second Test ${Date.now()}` }, + expect: (res) => res.household && res.household.id, + onSuccess: (res) => { secondHouseholdId = res.household.id; } + }, + { + name: "Verify user belongs to multiple households", + method: "GET", + endpoint: "/households", + auth: true, + expect: (res) => Array.isArray(res) && res.length >= 3 + }, + { + name: "Delete created test household", + method: "DELETE", + endpoint: () => `/households/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expect: (res) => res.message + }, + { + name: "Verify deleted household is gone", + method: "GET", + endpoint: () => `/households/${createdHouseholdId}`, + auth: true, + skip: () => !createdHouseholdId, + expectFail: true, + expect: (res, status) => status === 404 || status === 403 + }, + { + name: "Delete second test household", + method: "DELETE", + endpoint: () => `/households/${secondHouseholdId}`, + auth: true, + skip: () => !secondHouseholdId, + expect: (res) => res.message + }, + { + name: "Verify households list updated", + method: "GET", + endpoint: "/households", + auth: true, + expect: (res) => Array.isArray(res) + } + ] + } + ]; + + async function makeRequest(test) { + const apiUrl = document.getElementById('apiUrl').value; + const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint; + const url = `${apiUrl}${endpoint}`; + + const options = { + method: test.method, + headers: { + 'Content-Type': 'application/json', + } + }; + + if (test.auth && authToken) { + options.headers['Authorization'] = `Bearer ${authToken}`; + } + + if (test.body) { + options.body = JSON.stringify(typeof test.body === 'function' ? test.body() : test.body); + } + + const response = await fetch(url, options); + const data = await response.json().catch(() => ({})); + + return { data, status: response.status }; + } + + async function runTest(categoryIdx, testIdx) { + const test = tests[categoryIdx].tests[testIdx]; + const testId = `test-${categoryIdx}-${testIdx}`; + const testEl = document.getElementById(testId); + const contentEl = document.getElementById(`${testId}-content`); + const toggleEl = document.getElementById(`${testId}-toggle`); + const resultEl = testEl.querySelector('.test-result'); + + // Auto-expand when running + contentEl.classList.add('expanded'); + toggleEl.classList.add('expanded'); + resultEl.style.display = 'block'; + resultEl.className = 'test-result'; + resultEl.innerHTML = '⚠️ Prerequisites not met'; + return 'skip'; + } + + testEl.className = 'test-case running'; + testEl.querySelector('.test-status').textContent = 'RUNNING'; + testEl.querySelector('.test-status').className = 'test-status running'; + resultEl.style.display = 'none'; + + try { + const { data, status } = await makeRequest(test); + + const expectFail = test.expectFail || false; + const passed = test.expect(data, status); + + const success = expectFail ? !passed || status >= 400 : passed; + + testEl.className = success ? 'test-case pass' : 'test-case fail'; + testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL'; + testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`; + + // Determine status code class + let statusClass = 'status-5xx'; + if (status >= 200 && status < 300) statusClass = 'status-2xx'; + else if (status >= 300 && status < 400) statusClass = 'status-3xx'; + else if (status >= 400 && status < 500) statusClass = 'status-4xx'; + + resultEl.style.display = 'block'; + resultEl.className = 'test-result'; + + // Check expected fields if defined + let expectedFieldsHTML = ''; + if (test.expectedFields) { + const fieldChecks = test.expectedFields.map(field => { + const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined; + const icon = exists ? '✓' : '✗'; + const className = exists ? 'pass' : 'fail'; + return `
${icon} ${field}
`; + }).join(''); + + expectedFieldsHTML = ` +
+
Expected Fields:
+ ${fieldChecks} +
+ `; + } + + resultEl.innerHTML = ` +
+ HTTP ${status} + ${success ? '✓ Test passed' : '✗ Test failed'} +
+ ${expectedFieldsHTML} +
Response:
+
${JSON.stringify(data, null, 2)}
+ `; + + if (success && test.onSuccess) { + test.onSuccess(data); + } + + return success ? 'pass' : 'fail'; + } catch (error) { + testEl.className = 'test-case fail'; + testEl.querySelector('.test-status').textContent = 'ERROR'; + testEl.querySelector('.test-status').className = 'test-status fail'; + + resultEl.style.display = 'block'; + resultEl.className = 'test-error'; + resultEl.innerHTML = ` +
❌ Network/Request Error
+
${error.message}
+ ${error.stack ? `
${error.stack}
` : ''} + `; + return 'fail'; + } + } + + async function runAllTests(event) { + authToken = null; + householdId = null; + storeId = null; + testUserId = null; + createdHouseholdId = null; + secondHouseholdId = null; + inviteCode = null; + + const button = event.target; + button.disabled = true; + button.textContent = '⏳ Running Tests...'; + + let totalTests = 0; + let passedTests = 0; + let failedTests = 0; + + for (let i = 0; i < tests.length; i++) { + for (let j = 0; j < tests[i].tests.length; j++) { + const result = await runTest(i, j); + if (result !== 'skip') { + totalTests++; + if (result === 'pass') passedTests++; + if (result === 'fail') failedTests++; + } + } + } + + document.getElementById('summary').style.display = 'flex'; + document.getElementById('totalTests').textContent = totalTests; + document.getElementById('passedTests').textContent = passedTests; + document.getElementById('failedTests').textContent = failedTests; + + button.disabled = false; + button.textContent = '▶ Run All Tests'; + } + + function toggleTest(testId) { + const content = document.getElementById(`${testId}-content`); + const toggle = document.getElementById(`${testId}-toggle`); + + if (content.classList.contains('expanded')) { + content.classList.remove('expanded'); + toggle.classList.remove('expanded'); + } else { + content.classList.add('expanded'); + toggle.classList.add('expanded'); + } + } + + function expandAllTests() { + document.querySelectorAll('.test-content').forEach(content => { + content.classList.add('expanded'); + }); + document.querySelectorAll('.toggle-icon').forEach(icon => { + icon.classList.add('expanded'); + }); + } + + function collapseAllTests() { + document.querySelectorAll('.test-content').forEach(content => { + content.classList.remove('expanded'); + }); + document.querySelectorAll('.toggle-icon').forEach(icon => { + icon.classList.remove('expanded'); + }); + } + + function clearResults() { + renderTests(); + document.getElementById('summary').style.display = 'none'; + authToken = null; + householdId = null; + storeId = null; + testUserId = null; + createdHouseholdId = null; + secondHouseholdId = null; + inviteCode = null; + } + + function renderTests() { + const container = document.getElementById('testResults'); + container.innerHTML = ''; + + tests.forEach((category, catIdx) => { + const categoryDiv = document.createElement('div'); + categoryDiv.className = 'test-category'; + + const categoryHeader = document.createElement('h2'); + categoryHeader.textContent = category.category; + categoryDiv.appendChild(categoryHeader); + + category.tests.forEach((test, testIdx) => { + const testDiv = document.createElement('div'); + testDiv.className = 'test-case'; + testDiv.id = `test-${catIdx}-${testIdx}`; + + const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint; + + testDiv.innerHTML = ` +
+
+ + ${test.name} +
+
PENDING
+
+
+
+ ${test.method} ${endpoint} + ${test.expectFail ? ' (Expected to fail)' : ''} + ${test.auth ? ' 🔒 Requires Auth' : ''} +
+ +
+ `; + + categoryDiv.appendChild(testDiv); + }); + + container.appendChild(categoryDiv); + }); + } + + // Initialize + renderTests(); diff --git a/backend/public/test-styles.css b/backend/public/test-styles.css new file mode 100644 index 0000000..0a34c99 --- /dev/null +++ b/backend/public/test-styles.css @@ -0,0 +1,309 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; + padding: 20px; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +h1 { + color: #333; + margin-bottom: 10px; +} + +.config { + background: white; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.config-row { + display: flex; + gap: 10px; + margin-bottom: 10px; + align-items: center; +} + +.config-row label { + min-width: 100px; + font-weight: 500; +} + +.config-row input { + flex: 1; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +button { + background: #0066cc; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; +} + +button:hover { + background: #0052a3; +} + +button:disabled { + background: #ccc; + cursor: not-allowed; +} + +.test-category { + background: white; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.test-category h2 { + color: #333; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 2px solid #eee; +} + +.test-case { + padding: 15px; + margin-bottom: 10px; + border-radius: 6px; + border-left: 4px solid #ddd; + background: #f9f9f9; +} + +.test-case.running { + border-left-color: #ffa500; + background: #fff8e6; +} + +.test-case.pass { + border-left-color: #28a745; + background: #e8f5e9; +} + +.test-case.fail { + border-left-color: #dc3545; + background: #ffebee; +} + +.test-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + cursor: pointer; + user-select: none; +} + +.test-header:hover { + background: rgba(0, 0, 0, 0.02); + margin: -5px; + padding: 5px; + border-radius: 4px; +} + +.toggle-icon { + font-size: 12px; + margin-right: 8px; + transition: transform 0.2s; + display: inline-block; +} + +.toggle-icon.expanded { + transform: rotate(90deg); +} + +.test-content { + display: none; +} + +.test-content.expanded { + display: block; +} + +.test-name { + font-weight: 600; + font-size: 15px; + display: flex; + align-items: center; +} + +.test-status { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.test-status.pending { + background: #e0e0e0; + color: #666; +} + +.test-status.running { + background: #ffa500; + color: white; +} + +.test-status.pass { + background: #28a745; + color: white; +} + +.test-status.fail { + background: #dc3545; + color: white; +} + +.test-details { + font-size: 13px; + color: #666; + margin-bottom: 5px; +} + +.test-result { + font-size: 13px; + margin-top: 8px; + padding: 10px; + background: white; + border-radius: 4px; + font-family: monospace; + white-space: pre-wrap; + word-break: break-all; +} + +.expected-section { + margin-top: 8px; + padding: 8px; + background: #f0f7ff; + border-left: 3px solid #2196f3; + border-radius: 4px; + font-size: 12px; +} + +.expected-label { + font-weight: bold; + color: #1976d2; + margin-bottom: 4px; +} + +.field-check { + margin: 2px 0; + padding-left: 16px; +} + +.field-check.pass { + color: #2e7d32; +} + +.field-check.fail { + color: #c62828; +} + +.test-error { + font-size: 13px; + margin-top: 8px; + padding: 10px; + background: #fff5f5; + border: 1px solid #ffcdd2; + border-radius: 4px; + color: #c62828; + font-family: monospace; + white-space: pre-wrap; +} + +.response-status { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 12px; + margin-right: 8px; +} + +.status-2xx { + background: #c8e6c9; + color: #2e7d32; +} + +.status-3xx { + background: #fff9c4; + color: #f57f17; +} + +.status-4xx { + background: #ffccbc; + color: #d84315; +} + +.status-5xx { + background: #ffcdd2; + color: #c62828; +} + +.summary { + background: white; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + display: flex; + gap: 20px; +} + +.summary-item { + flex: 1; + text-align: center; + padding: 15px; + border-radius: 6px; +} + +.summary-item.total { + background: #e3f2fd; +} + +.summary-item.pass { + background: #e8f5e9; +} + +.summary-item.fail { + background: #ffebee; +} + +.summary-value { + font-size: 32px; + font-weight: bold; + margin-bottom: 5px; +} + +.summary-label { + font-size: 14px; + color: #666; + text-transform: uppercase; +} + +.actions { + display: flex; + gap: 10px; + margin-bottom: 20px; +} diff --git a/backend/public/test-ui.js b/backend/public/test-ui.js new file mode 100644 index 0000000..35f9c53 --- /dev/null +++ b/backend/public/test-ui.js @@ -0,0 +1,85 @@ +function toggleTest(testId) { + const content = document.getElementById(`${testId}-content`); + const toggle = document.getElementById(`${testId}-toggle`); + + if (content.classList.contains('expanded')) { + content.classList.remove('expanded'); + toggle.classList.remove('expanded'); + } else { + content.classList.add('expanded'); + toggle.classList.add('expanded'); + } +} + +function expandAllTests() { + document.querySelectorAll('.test-content').forEach(content => { + content.classList.add('expanded'); + }); + document.querySelectorAll('.toggle-icon').forEach(icon => { + icon.classList.add('expanded'); + }); +} + +function collapseAllTests() { + document.querySelectorAll('.test-content').forEach(content => { + content.classList.remove('expanded'); + }); + document.querySelectorAll('.toggle-icon').forEach(icon => { + icon.classList.remove('expanded'); + }); +} + +function clearResults() { + renderTests(); + document.getElementById('summary').style.display = 'none'; + resetState(); +} + +function renderTests() { + const container = document.getElementById('testResults'); + container.innerHTML = ''; + + tests.forEach((category, catIdx) => { + const categoryDiv = document.createElement('div'); + categoryDiv.className = 'test-category'; + + const categoryHeader = document.createElement('h2'); + categoryHeader.textContent = category.category; + categoryDiv.appendChild(categoryHeader); + + category.tests.forEach((test, testIdx) => { + const testDiv = document.createElement('div'); + testDiv.className = 'test-case'; + testDiv.id = `test-${catIdx}-${testIdx}`; + + const endpoint = typeof test.endpoint === 'function' ? test.endpoint() : test.endpoint; + + testDiv.innerHTML = ` +
+
+ + ${test.name} +
+
PENDING
+
+
+
+ ${test.method} ${endpoint} + ${test.expectFail ? ' (Expected to fail)' : ''} + ${test.auth ? ' 🔒 Requires Auth' : ''} +
+ +
+ `; + + categoryDiv.appendChild(testDiv); + }); + + container.appendChild(categoryDiv); + }); +} + +// Initialize on page load +document.addEventListener('DOMContentLoaded', function () { + renderTests(); +}); diff --git a/backend/routes/admin.routes.js b/backend/routes/admin.routes.js index 3dc9bd8..d1f1057 100644 --- a/backend/routes/admin.routes.js +++ b/backend/routes/admin.routes.js @@ -4,8 +4,9 @@ const requireRole = require("../middleware/rbac"); const usersController = require("../controllers/users.controller"); const { ROLES } = require("../models/user.model"); -router.get("/users", auth, requireRole(ROLES.ADMIN), usersController.getAllUsers); -router.put("/users", auth, requireRole(ROLES.ADMIN), usersController.updateUserRole); -router.delete("/users", auth, requireRole(ROLES.ADMIN), usersController.deleteUser); +// router.get("/users", auth, (req, res, next) => next(), usersController.getAllUsers); +router.get("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.getAllUsers); +router.put("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.updateUserRole); +router.delete("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.deleteUser); module.exports = router; diff --git a/backend/routes/auth.routes.js b/backend/routes/auth.routes.js index 75db9de..dcf2300 100644 --- a/backend/routes/auth.routes.js +++ b/backend/routes/auth.routes.js @@ -1,13 +1,30 @@ -const router = require("express").Router(); -const controller = require("../controllers/auth.controller"); - -router.post("/register", controller.register); -router.post("/login", controller.login); -router.post("/", async (req, res) => { - resText = `Grocery List API is running.\n` + - `Roles available: ${Object.values(User.ROLES).join(', ')}` - - res.status(200).type("text/plain").send(resText); -}); +const router = require("express").Router(); +const controller = require("../controllers/auth.controller"); +const User = require("../models/user.model"); +const { createRateLimit } = require("../middleware/rate-limit"); + +const loginRateLimit = createRateLimit({ + keyPrefix: "auth:login", + windowMs: 15 * 60 * 1000, + max: 25, + message: "Too many login attempts. Please try again later.", +}); + +const registerRateLimit = createRateLimit({ + keyPrefix: "auth:register", + windowMs: 15 * 60 * 1000, + max: 10, + message: "Too many registration attempts. Please try again later.", +}); + +router.post("/register", registerRateLimit, controller.register); +router.post("/login", loginRateLimit, controller.login); +router.post("/logout", controller.logout); +router.post("/", async (req, res) => { + res.status(200).json({ + message: "Auth API is running.", + roles: Object.values(User.ROLES), + }); +}); module.exports = router; diff --git a/backend/routes/group-invites.routes.js b/backend/routes/group-invites.routes.js new file mode 100644 index 0000000..a05908d --- /dev/null +++ b/backend/routes/group-invites.routes.js @@ -0,0 +1,79 @@ +const router = require("express").Router(); +const auth = require("../middleware/auth"); +const optionalAuth = require("../middleware/optional-auth"); +const { createRateLimit } = require("../middleware/rate-limit"); +const controller = require("../controllers/group-invites.controller"); + +const inviteSummaryIpRateLimit = createRateLimit({ + keyPrefix: "invite:summary:ip", + windowMs: 15 * 60 * 1000, + max: 120, + message: "Too many invite link summary requests. Please try again later.", +}); + +const inviteAcceptIpRateLimit = createRateLimit({ + keyPrefix: "invite:accept:ip", + windowMs: 15 * 60 * 1000, + max: 60, + message: "Too many invite acceptance attempts. Please try again later.", +}); + +const inviteWriteUserRateLimit = createRateLimit({ + keyPrefix: "invite:write:user", + windowMs: 15 * 60 * 1000, + max: 60, + message: "Too many write operations. Please try again later.", + keyFn: (req) => (req.user?.id ? `user:${req.user.id}` : "anon"), +}); + +router.get("/groups/invites", auth, controller.listInviteLinks); +router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink); +router.get("/groups/join-requests", auth, controller.listPendingJoinRequests); +router.post( + "/groups/join-requests/decision", + auth, + inviteWriteUserRateLimit, + controller.decideJoinRequest +); +router.post( + "/groups/invites/revoke", + auth, + inviteWriteUserRateLimit, + controller.revokeInviteLink +); +router.post( + "/groups/invites/revive", + auth, + inviteWriteUserRateLimit, + controller.reviveInviteLink +); +router.post( + "/groups/invites/delete", + auth, + inviteWriteUserRateLimit, + controller.deleteInviteLink +); + +router.get("/groups/join-policy", auth, controller.getJoinPolicy); +router.post( + "/groups/join-policy", + auth, + inviteWriteUserRateLimit, + controller.setJoinPolicy +); + +router.get( + "/invite-links/:token", + inviteSummaryIpRateLimit, + optionalAuth, + controller.getInviteLinkSummary +); +router.post( + "/invite-links/:token", + auth, + inviteAcceptIpRateLimit, + inviteWriteUserRateLimit, + controller.acceptInviteLink +); + +module.exports = router; diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js new file mode 100644 index 0000000..20aef5a --- /dev/null +++ b/backend/routes/households.routes.js @@ -0,0 +1,214 @@ +const express = require("express"); +const router = express.Router(); +const controller = require("../controllers/households.controller"); +const listsController = require("../controllers/lists.controller.v2"); +const availableItemsController = require("../controllers/available-items.controller"); +const auth = require("../middleware/auth"); +const { + householdAccess, + requireHouseholdAdmin, + storeAccess, +} = require("../middleware/household"); +const { upload, processImage } = require("../middleware/image"); + +// Public routes (authenticated only) +router.get("/", auth, controller.getUserHouseholds); +router.post("/", auth, controller.createHousehold); +router.post("/join/:inviteCode", auth, controller.joinHousehold); + +// Household-scoped routes (member access required) +router.get("/:householdId", auth, householdAccess, controller.getHousehold); +router.patch( + "/:householdId", + auth, + householdAccess, + requireHouseholdAdmin, + controller.updateHousehold +); +router.delete( + "/:householdId", + auth, + householdAccess, + requireHouseholdAdmin, + controller.deleteHousehold +); +router.post( + "/:householdId/invite/refresh", + auth, + householdAccess, + requireHouseholdAdmin, + controller.refreshInviteCode +); + +router.get( + "/:householdId/stores/:storeId/available-items", + auth, + householdAccess, + storeAccess, + availableItemsController.getAvailableItems +); +router.post( + "/:householdId/stores/:storeId/available-items", + auth, + householdAccess, + storeAccess, + requireHouseholdAdmin, + upload.single("image"), + processImage, + availableItemsController.createAvailableItem +); +router.patch( + "/:householdId/stores/:storeId/available-items/:itemId", + auth, + householdAccess, + storeAccess, + requireHouseholdAdmin, + upload.single("image"), + processImage, + availableItemsController.updateAvailableItem +); +router.delete( + "/:householdId/stores/:storeId/available-items/:itemId", + auth, + householdAccess, + storeAccess, + requireHouseholdAdmin, + availableItemsController.deleteAvailableItem +); +router.post( + "/:householdId/stores/:storeId/available-items/import-current", + auth, + householdAccess, + storeAccess, + requireHouseholdAdmin, + availableItemsController.importCurrentItems +); + +// Member management routes +router.get( + "/:householdId/members", + auth, + householdAccess, + controller.getMembers +); +router.patch( + "/:householdId/members/:userId/role", + auth, + householdAccess, + requireHouseholdAdmin, + controller.updateMemberRole +); +router.delete( + "/:householdId/members/:userId", + auth, + householdAccess, + controller.removeMember +); + +// ==================== List Operations Routes ==================== +// All list routes require household access AND store access + +// Get grocery list +router.get( + "/:householdId/stores/:storeId/list", + auth, + householdAccess, + storeAccess, + listsController.getList +); + +// Get specific item by name +router.get( + "/:householdId/stores/:storeId/list/item", + auth, + householdAccess, + storeAccess, + listsController.getItemByName +); + +// Add item to list +router.post( + "/:householdId/stores/:storeId/list/add", + auth, + householdAccess, + storeAccess, + upload.single("image"), + processImage, + listsController.addItem +); + +// Mark item as bought/unbought +router.patch( + "/:householdId/stores/:storeId/list/item", + auth, + householdAccess, + storeAccess, + listsController.markBought +); + +// Update item details (quantity, notes) +router.put( + "/:householdId/stores/:storeId/list/item", + auth, + householdAccess, + storeAccess, + listsController.updateItem +); + +// Delete item +router.delete( + "/:householdId/stores/:storeId/list/item", + auth, + householdAccess, + storeAccess, + listsController.deleteItem +); + +// Get suggestions +router.get( + "/:householdId/stores/:storeId/list/suggestions", + auth, + householdAccess, + storeAccess, + listsController.getSuggestions +); + +// Get recently bought items +router.get( + "/:householdId/stores/:storeId/list/recent", + auth, + householdAccess, + storeAccess, + listsController.getRecentlyBought +); + +// Get item classification +router.get( + "/:householdId/stores/:storeId/list/classification", + auth, + householdAccess, + storeAccess, + listsController.getClassification +); + +// Set item classification +router.post( + "/:householdId/stores/:storeId/list/classification", + auth, + householdAccess, + storeAccess, + listsController.setClassification +); + +// Update item image +router.post( + "/:householdId/stores/:storeId/list/update-image", + auth, + householdAccess, + storeAccess, + upload.single("image"), + processImage, + listsController.updateItemImage +); + +module.exports = router; diff --git a/backend/routes/stores.routes.js b/backend/routes/stores.routes.js new file mode 100644 index 0000000..f7f16e7 --- /dev/null +++ b/backend/routes/stores.routes.js @@ -0,0 +1,48 @@ +const express = require("express"); +const router = express.Router(); +const controller = require("../controllers/stores.controller"); +const auth = require("../middleware/auth"); +const { + householdAccess, + requireHouseholdAdmin, + requireSystemAdmin, +} = require("../middleware/household"); + +// Public routes +router.get("/", auth, controller.getAllStores); + +// Household store management +router.get( + "/household/:householdId", + auth, + householdAccess, + controller.getHouseholdStores +); +router.post( + "/household/:householdId", + auth, + householdAccess, + requireHouseholdAdmin, + controller.addStoreToHousehold +); +router.delete( + "/household/:householdId/:storeId", + auth, + householdAccess, + requireHouseholdAdmin, + controller.removeStoreFromHousehold +); +router.patch( + "/household/:householdId/:storeId/default", + auth, + householdAccess, + requireHouseholdAdmin, + controller.setDefaultStore +); + +// System admin routes +router.post("/", auth, requireSystemAdmin, controller.createStore); +router.patch("/:storeId", auth, requireSystemAdmin, controller.updateStore); +router.delete("/:storeId", auth, requireSystemAdmin, controller.deleteStore); + +module.exports = router; diff --git a/backend/routes/users.routes.js b/backend/routes/users.routes.js index 2a8796b..e3d8d14 100644 --- a/backend/routes/users.routes.js +++ b/backend/routes/users.routes.js @@ -1,11 +1,21 @@ const router = require("express").Router(); -const auth = require("../middleware/auth"); -const requireRole = require("../middleware/rbac"); -const usersController = require("../controllers/users.controller"); -const { ROLES } = require("../models/user.model"); - -router.get("/exists", usersController.checkIfUserExists); -router.get("/test", usersController.test); +const auth = require("../middleware/auth"); +const requireRole = require("../middleware/rbac"); +const usersController = require("../controllers/users.controller"); +const { ROLES } = require("../models/user.model"); +const { createRateLimit } = require("../middleware/rate-limit"); + +const userExistsRateLimit = createRateLimit({ + keyPrefix: "users:exists", + windowMs: 15 * 60 * 1000, + max: 60, + message: "Too many availability checks. Please try again later.", +}); + +router.get("/exists", userExistsRateLimit, usersController.checkIfUserExists); +if (process.env.NODE_ENV !== "production") { + router.get("/test", usersController.test); +} // Current user profile routes (authenticated) router.get("/me", auth, usersController.getCurrentUser); diff --git a/backend/services/group-invites.service.js b/backend/services/group-invites.service.js new file mode 100644 index 0000000..69eb68c --- /dev/null +++ b/backend/services/group-invites.service.js @@ -0,0 +1,675 @@ +const crypto = require("crypto"); +const net = require("net"); +const invitesModel = require("../models/group-invites.model"); +const { inviteCodeLast4 } = require("../utils/redaction"); + +const JOIN_POLICIES = Object.freeze({ + NOT_ACCEPTING: "NOT_ACCEPTING", + AUTO_ACCEPT: "AUTO_ACCEPT", + APPROVAL_REQUIRED: "APPROVAL_REQUIRED", +}); + +const JOIN_RESULTS = Object.freeze({ + JOINED: "JOINED", + PENDING: "PENDING", + ALREADY_MEMBER: "ALREADY_MEMBER", +}); + +class InviteServiceError extends Error { + constructor(code, message, statusCode = 400) { + super(message); + this.name = "InviteServiceError"; + this.code = code; + this.statusCode = statusCode; + } +} + +function normalizeIp(ip) { + if (!ip || typeof ip !== "string") return null; + const trimmed = ip.trim(); + if (!trimmed) return null; + return net.isIP(trimmed) ? trimmed : null; +} + +function ensureJoinPolicy(policy) { + if (Object.values(JOIN_POLICIES).includes(policy)) { + return policy; + } + throw new InviteServiceError( + "INVALID_JOIN_POLICY", + "Invalid join policy", + 400 + ); +} + +function ensurePositiveInteger(value, fieldName) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InviteServiceError("INVALID_INPUT", `${fieldName} is required`, 400); + } + return parsed; +} + +function ensureDate(value, fieldName) { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw new InviteServiceError("INVALID_INPUT", `${fieldName} is invalid`, 400); + } + return date; +} + +async function ensureGroupAndManagerRole(userId, groupId, client) { + const group = await invitesModel.getGroupById(groupId, client); + if (!group) { + throw new InviteServiceError("GROUP_NOT_FOUND", "Group not found", 404); + } + + const actorRole = await invitesModel.getUserGroupRole(groupId, userId, client); + if (!["owner", "admin"].includes(actorRole)) { + throw new InviteServiceError( + "FORBIDDEN", + "Admin or owner role required", + 403 + ); + } + + return { actorRole, group }; +} + +async function resolveManagedGroupId(userId, requestedGroupId) { + if (requestedGroupId !== undefined && requestedGroupId !== null) { + return ensurePositiveInteger(requestedGroupId, "groupId"); + } + + const manageableGroups = await invitesModel.getManageableGroupsForUser(userId); + if (manageableGroups.length === 0) { + throw new InviteServiceError( + "FORBIDDEN", + "Admin or owner role required", + 403 + ); + } + + if (manageableGroups.length > 1) { + throw new InviteServiceError( + "GROUP_ID_REQUIRED", + "Group ID is required when you manage multiple groups", + 400 + ); + } + + return manageableGroups[0].group_id; +} + +async function createInviteLink( + userId, + groupId, + policy, + singleUse, + expiresAt, + requestId, + ip, + userAgent +) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + const resolvedPolicy = ensureJoinPolicy(policy); + const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt"); + + return invitesModel.withTransaction(async (client) => { + const { actorRole } = await ensureGroupAndManagerRole( + userId, + resolvedGroupId, + client + ); + + let link = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + const token = crypto.randomBytes(16).toString("hex"); + try { + link = await invitesModel.createInviteLink( + { + groupId: resolvedGroupId, + createdBy: userId, + token, + policy: resolvedPolicy, + singleUse: Boolean(singleUse), + expiresAt: resolvedExpiresAt, + }, + client + ); + break; + } catch (error) { + if (error.code !== "23505") { + throw error; + } + } + } + + if (!link) { + throw new InviteServiceError( + "INVITE_CREATE_FAILED", + "Unable to create invite link", + 500 + ); + } + + await invitesModel.createGroupAuditLog( + { + groupId: resolvedGroupId, + actorUserId: userId, + actorRole, + eventType: "GROUP_INVITE_CREATED", + requestId, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + inviteCodeLast4: inviteCodeLast4(link.token), + }, + }, + client + ); + + return link; + }); +} + +async function listInviteLinks(userId, groupId) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + await ensureGroupAndManagerRole(userId, resolvedGroupId); + return invitesModel.listInviteLinks(resolvedGroupId); +} + +async function listPendingJoinRequests(userId, groupId) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + await ensureGroupAndManagerRole(userId, resolvedGroupId); + return invitesModel.listPendingJoinRequests(resolvedGroupId); +} + +async function revokeInviteLink( + userId, + groupId, + linkId, + requestId, + ip, + userAgent +) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + const resolvedLinkId = ensurePositiveInteger(linkId, "linkId"); + + return invitesModel.withTransaction(async (client) => { + const { actorRole } = await ensureGroupAndManagerRole( + userId, + resolvedGroupId, + client + ); + const link = await invitesModel.revokeInviteLink( + resolvedGroupId, + resolvedLinkId, + client + ); + if (!link) { + throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); + } + + await invitesModel.createGroupAuditLog( + { + groupId: resolvedGroupId, + actorUserId: userId, + actorRole, + eventType: "GROUP_INVITE_REVOKED", + requestId, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + inviteCodeLast4: inviteCodeLast4(link.token), + }, + }, + client + ); + }); +} + +async function reviveInviteLink( + userId, + groupId, + linkId, + expiresAt, + requestId, + ip, + userAgent +) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + const resolvedLinkId = ensurePositiveInteger(linkId, "linkId"); + const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt"); + + return invitesModel.withTransaction(async (client) => { + const { actorRole } = await ensureGroupAndManagerRole( + userId, + resolvedGroupId, + client + ); + const link = await invitesModel.reviveInviteLink( + resolvedGroupId, + resolvedLinkId, + resolvedExpiresAt, + client + ); + if (!link) { + throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); + } + + await invitesModel.createGroupAuditLog( + { + groupId: resolvedGroupId, + actorUserId: userId, + actorRole, + eventType: "GROUP_INVITE_REVIVED", + requestId, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + inviteCodeLast4: inviteCodeLast4(link.token), + }, + }, + client + ); + }); +} + +async function deleteInviteLink( + userId, + groupId, + linkId, + requestId, + ip, + userAgent +) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + const resolvedLinkId = ensurePositiveInteger(linkId, "linkId"); + + return invitesModel.withTransaction(async (client) => { + const { actorRole } = await ensureGroupAndManagerRole( + userId, + resolvedGroupId, + client + ); + const link = await invitesModel.deleteInviteLink( + resolvedGroupId, + resolvedLinkId, + client + ); + if (!link) { + throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); + } + + await invitesModel.createGroupAuditLog( + { + groupId: resolvedGroupId, + actorUserId: userId, + actorRole, + eventType: "GROUP_INVITE_DELETED", + requestId, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + inviteCodeLast4: inviteCodeLast4(link.token), + }, + }, + client + ); + }); +} + +async function decideJoinRequest( + userId, + groupId, + requestId, + decision, + requestIdForAudit, + ip, + userAgent +) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + const resolvedRequestId = ensurePositiveInteger(requestId, "requestId"); + const normalizedDecision = typeof decision === "string" ? decision.trim().toUpperCase() : ""; + + if (!["APPROVE", "DENY"].includes(normalizedDecision)) { + throw new InviteServiceError("INVALID_INPUT", "Decision is required", 400); + } + + return invitesModel.withTransaction(async (client) => { + const { actorRole } = await ensureGroupAndManagerRole( + userId, + resolvedGroupId, + client + ); + const pendingRequest = await invitesModel.getPendingJoinRequestById( + resolvedGroupId, + resolvedRequestId, + client, + true + ); + + if (!pendingRequest) { + throw new InviteServiceError( + "JOIN_REQUEST_NOT_FOUND", + "Pending join request not found", + 404 + ); + } + + if (normalizedDecision === "APPROVE") { + const isExistingMember = await invitesModel.isGroupMember( + resolvedGroupId, + pendingRequest.user_id, + client + ); + if (!isExistingMember) { + await invitesModel.addGroupMember( + resolvedGroupId, + pendingRequest.user_id, + "member", + client + ); + } + + const approvedRequest = await invitesModel.updateJoinRequestDecision( + resolvedGroupId, + resolvedRequestId, + "APPROVED", + userId, + client + ); + + await invitesModel.createGroupAuditLog( + { + groupId: resolvedGroupId, + actorUserId: userId, + actorRole, + eventType: "GROUP_JOIN_REQUEST_APPROVED", + requestId: requestIdForAudit, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + joinRequestId: approvedRequest.id, + targetUserId: approvedRequest.user_id, + }, + }, + client + ); + + return approvedRequest; + } + + const deniedRequest = await invitesModel.updateJoinRequestDecision( + resolvedGroupId, + resolvedRequestId, + "DENIED", + userId, + client + ); + + await invitesModel.createGroupAuditLog( + { + groupId: resolvedGroupId, + actorUserId: userId, + actorRole, + eventType: "GROUP_JOIN_REQUEST_DENIED", + requestId: requestIdForAudit, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + joinRequestId: deniedRequest.id, + targetUserId: deniedRequest.user_id, + }, + }, + client + ); + + return deniedRequest; + }); +} + +function getInviteStatus(link) { + const now = Date.now(); + if (link.single_use && link.used_at) return "USED"; + if (link.revoked_at) return "REVOKED"; + if (new Date(link.expires_at).getTime() <= now) return "EXPIRED"; + return "ACTIVE"; +} + +async function getInviteLinkSummaryByToken(token, userId = null) { + if (!token || typeof token !== "string") { + throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400); + } + + const summary = await invitesModel.getInviteLinkSummaryByToken(token.trim()); + if (!summary) { + throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); + } + + let viewerStatus = null; + if (userId) { + const isMember = await invitesModel.isGroupMember(summary.group_id, userId); + if (isMember) { + viewerStatus = JOIN_RESULTS.ALREADY_MEMBER; + } else { + const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId); + if (pending) { + viewerStatus = JOIN_RESULTS.PENDING; + } + } + } + + const activePolicy = summary.current_join_policy || summary.policy; + return { + id: summary.id, + group_id: summary.group_id, + group_name: summary.group_name, + token: summary.token, + policy: summary.policy, + current_join_policy: summary.current_join_policy || null, + active_policy: activePolicy, + single_use: summary.single_use, + expires_at: summary.expires_at, + used_at: summary.used_at, + revoked_at: summary.revoked_at, + created_at: summary.created_at, + status: getInviteStatus(summary), + ...(viewerStatus ? { viewerStatus } : {}), + }; +} + +async function acceptInviteLink(userId, token, requestId, ip, userAgent) { + if (!userId) { + throw new InviteServiceError("UNAUTHORIZED", "Authentication required", 401); + } + if (!token || typeof token !== "string") { + throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400); + } + + return invitesModel.withTransaction(async (client) => { + const summary = await invitesModel.getInviteLinkSummaryByToken( + token.trim(), + client, + true + ); + if (!summary) { + throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); + } + + const group = { + id: summary.group_id, + name: summary.group_name, + }; + + const memberExists = await invitesModel.isGroupMember(summary.group_id, userId, client); + if (memberExists) { + return { status: JOIN_RESULTS.ALREADY_MEMBER, group }; + } + + const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId, client); + if (pending) { + return { status: JOIN_RESULTS.PENDING, group }; + } + + const now = Date.now(); + if (summary.revoked_at) { + throw new InviteServiceError( + "INVITE_REVOKED", + "This invite link has been revoked", + 410 + ); + } + if (new Date(summary.expires_at).getTime() <= now) { + throw new InviteServiceError( + "INVITE_EXPIRED", + "This invite link has expired", + 410 + ); + } + if (summary.single_use && summary.used_at) { + throw new InviteServiceError( + "INVITE_USED", + "This invite link has already been used", + 410 + ); + } + + const activePolicy = + summary.current_join_policy || summary.policy || JOIN_POLICIES.NOT_ACCEPTING; + if (activePolicy === JOIN_POLICIES.NOT_ACCEPTING) { + throw new InviteServiceError( + "JOIN_NOT_ACCEPTING", + "This group is not accepting new members", + 403 + ); + } + + const actorRole = (await invitesModel.getUserGroupRole(summary.group_id, userId, client)) || "guest"; + + if (activePolicy === JOIN_POLICIES.AUTO_ACCEPT) { + const inserted = await invitesModel.addGroupMember( + summary.group_id, + userId, + "member", + client + ); + if (!inserted) { + return { status: JOIN_RESULTS.ALREADY_MEMBER, group }; + } + + if (summary.single_use) { + await invitesModel.consumeSingleUseInvite(summary.id, client); + } + + await invitesModel.createGroupAuditLog( + { + groupId: summary.group_id, + actorUserId: userId, + actorRole, + eventType: "GROUP_INVITE_USED", + requestId, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + inviteCodeLast4: inviteCodeLast4(summary.token), + }, + }, + client + ); + + return { status: JOIN_RESULTS.JOINED, group }; + } + + if (activePolicy === JOIN_POLICIES.APPROVAL_REQUIRED) { + await invitesModel.createOrTouchPendingJoinRequest(summary.group_id, userId, client); + + if (summary.single_use) { + await invitesModel.consumeSingleUseInvite(summary.id, client); + } + + await invitesModel.createGroupAuditLog( + { + groupId: summary.group_id, + actorUserId: userId, + actorRole, + eventType: "GROUP_INVITE_REQUESTED", + requestId, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + inviteCodeLast4: inviteCodeLast4(summary.token), + }, + }, + client + ); + + return { status: JOIN_RESULTS.PENDING, group }; + } + + throw new InviteServiceError("INVALID_JOIN_POLICY", "Invalid join policy", 400); + }); +} + +async function getGroupJoinPolicy(userId, groupId) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + await ensureGroupAndManagerRole(userId, resolvedGroupId); + const settings = await invitesModel.getGroupSettings(resolvedGroupId); + return settings?.join_policy || JOIN_POLICIES.NOT_ACCEPTING; +} + +async function setGroupJoinPolicy( + userId, + groupId, + joinPolicy, + requestId, + ip, + userAgent +) { + const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); + const resolvedJoinPolicy = ensureJoinPolicy(joinPolicy); + + return invitesModel.withTransaction(async (client) => { + const { actorRole } = await ensureGroupAndManagerRole( + userId, + resolvedGroupId, + client + ); + await invitesModel.upsertGroupSettings(resolvedGroupId, resolvedJoinPolicy, client); + + await invitesModel.createGroupAuditLog( + { + groupId: resolvedGroupId, + actorUserId: userId, + actorRole, + eventType: "GROUP_JOIN_POLICY_UPDATED", + requestId, + ip: normalizeIp(ip), + userAgent: userAgent || null, + metadata: { + joinPolicy: resolvedJoinPolicy, + }, + }, + client + ); + }); +} + +module.exports = { + InviteServiceError, + JOIN_POLICIES, + JOIN_RESULTS, + acceptInviteLink, + createInviteLink, + decideJoinRequest, + deleteInviteLink, + getGroupJoinPolicy, + getInviteLinkSummaryByToken, + listPendingJoinRequests, + listInviteLinks, + resolveManagedGroupId, + revokeInviteLink, + reviveInviteLink, + setGroupJoinPolicy, +}; diff --git a/backend/tests/available-item.model.test.js b/backend/tests/available-item.model.test.js new file mode 100644 index 0000000..50d0923 --- /dev/null +++ b/backend/tests/available-item.model.test.js @@ -0,0 +1,96 @@ +jest.mock("../db/pool", () => ({ + query: jest.fn(), +})); + +const pool = require("../db/pool"); +const AvailableItems = require("../models/available-item.model"); + +describe("available-item.model", () => { + beforeEach(() => { + pool.query.mockReset(); + }); + + test("lists household store items", async () => { + pool.query.mockResolvedValueOnce({ + rowCount: 1, + rows: [ + { + item_id: 55, + item_name: "milk", + item_image: null, + image_mime_type: null, + item_type: null, + item_group: null, + zone: null, + has_managed_settings: false, + }, + ], + }); + + const result = await AvailableItems.listAvailableItems(1, 2); + + expect(result).toEqual([ + expect.objectContaining({ + item_id: 55, + item_name: "milk", + }), + ]); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("FROM household_store_items hsi"), + [1, 2] + ); + }); + + test("creates a household store item when needed", async () => { + pool.query + .mockResolvedValueOnce({ rowCount: 0, rows: [] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] }) + .mockResolvedValueOnce({ + rowCount: 1, + rows: [{ item_id: 77, item_name: "granola" }], + }); + + const result = await AvailableItems.createAvailableItem(1, 2, "Granola"); + + expect(result).toEqual(expect.objectContaining({ item_id: 77, item_name: "granola" })); + expect(pool.query).toHaveBeenNthCalledWith( + 2, + expect.stringContaining("INSERT INTO household_store_items"), + [1, 2, "granola", "granola"] + ); + }); + + test("updates household store item images and returns refreshed data", async () => { + const imageBuffer = Buffer.from("abc"); + pool.query + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] }) + .mockResolvedValueOnce({ + rowCount: 1, + rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }], + }); + + const result = await AvailableItems.updateAvailableItem(1, 2, 55, { + imageBuffer, + mimeType: "image/jpeg", + }); + + expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" })); + expect(pool.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining("UPDATE household_store_items"), + [1, 2, 55, imageBuffer, "image/jpeg"] + ); + }); + + test("deletes the household store item", async () => { + pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] }); + + const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55); + + expect(deleted).toBe(true); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("DELETE FROM household_store_items"), + [1, 2, 55] + ); + }); +}); diff --git a/backend/tests/available-items.controller.test.js b/backend/tests/available-items.controller.test.js new file mode 100644 index 0000000..7d7ce38 --- /dev/null +++ b/backend/tests/available-items.controller.test.js @@ -0,0 +1,199 @@ +jest.mock("../models/available-item.model", () => ({ + createAvailableItem: jest.fn(), + deleteAvailableItem: jest.fn(), + getAvailableItemById: jest.fn(), + importCurrentListItems: jest.fn(), + listAvailableItems: jest.fn(), + updateAvailableItem: jest.fn(), +})); + +jest.mock("../models/list.model.v2", () => ({ + deleteClassification: jest.fn(), + upsertClassification: jest.fn(), +})); + +jest.mock("../utils/logger", () => ({ + logError: jest.fn(), +})); + +const AvailableItems = require("../models/available-item.model"); +const List = require("../models/list.model.v2"); +const controller = require("../controllers/available-items.controller"); + +function createResponse() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe("available-items.controller", () => { + beforeEach(() => { + jest.clearAllMocks(); + AvailableItems.createAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" }); + AvailableItems.getAvailableItemById.mockResolvedValue({ + item_id: 99, + item_name: "milk", + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + }); + AvailableItems.updateAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" }); + AvailableItems.deleteAvailableItem.mockResolvedValue(true); + AvailableItems.importCurrentListItems.mockResolvedValue(2); + AvailableItems.listAvailableItems.mockResolvedValue([]); + List.upsertClassification.mockResolvedValue(undefined); + List.deleteClassification.mockResolvedValue(false); + }); + + test("creates an available item and persists classification metadata", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + classification: JSON.stringify({ + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + }), + }, + processedImage: null, + }; + const res = createResponse(); + + await controller.createAvailableItem(req, res); + + expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null); + expect(List.upsertClassification).toHaveBeenCalledWith( + "1", + "2", + 99, + expect.objectContaining({ + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + }) + ); + expect(res.status).toHaveBeenCalledWith(201); + }); + + test("rejects invalid item_group values", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + classification: JSON.stringify({ + item_type: "dairy", + item_group: "Bread", + }), + }, + }; + const res = createResponse(); + + await controller.createAvailableItem(req, res); + + expect(AvailableItems.createAvailableItem).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Invalid item_group for selected item_type", + }), + }) + ); + }); + + test("clears classification on update when classification is explicitly empty", async () => { + const req = { + params: { householdId: "1", storeId: "2", itemId: "99" }, + body: { + classification: "null", + }, + processedImage: null, + }; + const res = createResponse(); + + await controller.updateAvailableItem(req, res); + + expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99); + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + test("imports current list items and reports the import count", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + }; + const res = createResponse(); + + await controller.importCurrentItems(req, res); + + expect(AvailableItems.importCurrentListItems).toHaveBeenCalledWith("1", "2"); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + imported_count: 2, + }) + ); + }); + + test("deletes a store item", async () => { + const req = { + params: { householdId: "1", storeId: "2", itemId: "99" }, + }; + const res = createResponse(); + + await controller.deleteAvailableItem(req, res); + + expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99); + expect(List.deleteClassification).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" }); + }); + + test("returns an empty catalog payload when the available items table is missing", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + query: {}, + }; + const res = createResponse(); + + AvailableItems.listAvailableItems.mockRejectedValueOnce({ + code: "42P01", + message: 'relation "household_store_items" does not exist', + }); + + await controller.getAvailableItems(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + catalog_ready: false, + }) + ); + }); + + test("returns a setup error when creating while the available items table is missing", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + }, + processedImage: null, + }; + const res = createResponse(); + + AvailableItems.createAvailableItem.mockRejectedValueOnce({ + code: "42P01", + message: 'relation "household_store_items" does not exist', + }); + + await controller.createAvailableItem(req, res); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringContaining("latest database migration"), + }), + }) + ); + }); +}); diff --git a/backend/tests/available-items.routes.test.js b/backend/tests/available-items.routes.test.js new file mode 100644 index 0000000..05d5784 --- /dev/null +++ b/backend/tests/available-items.routes.test.js @@ -0,0 +1,109 @@ +jest.mock("../middleware/auth", () => (req, res, next) => { + req.user = { id: 42, role: "user" }; + next(); +}); + +jest.mock("../middleware/household", () => ({ + householdAccess: (req, res, next) => { + req.household = { + id: Number.parseInt(req.params.householdId, 10), + role: req.headers["x-household-role"] || "user", + }; + next(); + }, + requireHouseholdAdmin: (req, res, next) => { + if (["owner", "admin"].includes(req.household?.role)) { + return next(); + } + return res.status(403).json({ + error: { code: "FORBIDDEN", message: "Admin role required" }, + request_id: req.request_id, + }); + }, + storeAccess: (req, res, next) => next(), +})); + +jest.mock("../middleware/image", () => ({ + upload: { + single: () => (req, res, next) => next(), + }, + processImage: (req, res, next) => next(), +})); + +jest.mock("../controllers/households.controller", () => ({ + createHousehold: jest.fn(), + deleteHousehold: jest.fn(), + getHousehold: jest.fn(), + getMembers: jest.fn(), + getUserHouseholds: jest.fn(), + joinHousehold: jest.fn(), + refreshInviteCode: jest.fn(), + removeMember: jest.fn(), + updateHousehold: jest.fn(), + updateMemberRole: jest.fn(), +})); + +jest.mock("../controllers/lists.controller.v2", () => ({ + addItem: jest.fn(), + deleteItem: jest.fn(), + getClassification: jest.fn(), + getItemByName: jest.fn(), + getList: jest.fn(), + getRecentlyBought: jest.fn(), + getSuggestions: jest.fn(), + markBought: jest.fn(), + setClassification: jest.fn(), + updateItem: jest.fn(), + updateItemImage: jest.fn(), +})); + +jest.mock("../controllers/available-items.controller", () => ({ + createAvailableItem: jest.fn((req, res) => res.status(201).json({ message: "created" })), + deleteAvailableItem: jest.fn((req, res) => res.json({ message: "deleted" })), + getAvailableItems: jest.fn((req, res) => res.json({ items: [] })), + importCurrentItems: jest.fn((req, res) => res.json({ imported_count: 1 })), + updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })), +})); + +const express = require("express"); +const request = require("supertest"); +const router = require("../routes/households.routes"); +const availableItemsController = require("../controllers/available-items.controller"); + +describe("available-items routes", () => { + let app; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use("/households", router); + jest.clearAllMocks(); + }); + + test("members can read available items", async () => { + const response = await request(app).get("/households/1/stores/2/available-items"); + + expect(response.status).toBe(200); + expect(availableItemsController.getAvailableItems).toHaveBeenCalled(); + }); + + test("members cannot mutate available items", async () => { + const response = await request(app) + .post("/households/1/stores/2/available-items") + .set("x-household-role", "user") + .send({ item_name: "milk" }); + + expect(response.status).toBe(403); + expect(availableItemsController.createAvailableItem).not.toHaveBeenCalled(); + }); + + test("admins can create available items", async () => { + const response = await request(app) + .post("/households/1/stores/2/available-items") + .set("x-household-role", "admin") + .send({ item_name: "milk" }); + + expect(response.status).toBe(201); + expect(availableItemsController.createAvailableItem).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/group-invites.routes.test.js b/backend/tests/group-invites.routes.test.js new file mode 100644 index 0000000..a8b86df --- /dev/null +++ b/backend/tests/group-invites.routes.test.js @@ -0,0 +1,110 @@ +jest.mock("../middleware/auth", () => (req, res, next) => { + req.user = { id: 42, role: "user" }; + next(); +}); + +jest.mock("../middleware/optional-auth", () => (req, res, next) => next()); + +jest.mock("../services/group-invites.service", () => { + const actual = jest.requireActual("../services/group-invites.service"); + return { + ...actual, + acceptInviteLink: jest.fn(), + createInviteLink: jest.fn(), + deleteInviteLink: jest.fn(), + decideJoinRequest: jest.fn(), + getGroupJoinPolicy: jest.fn(), + getInviteLinkSummaryByToken: jest.fn(), + listPendingJoinRequests: jest.fn(), + listInviteLinks: jest.fn(), + resolveManagedGroupId: jest.fn(), + revokeInviteLink: jest.fn(), + reviveInviteLink: jest.fn(), + setGroupJoinPolicy: jest.fn(), + }; +}); + +const request = require("supertest"); +const invitesService = require("../services/group-invites.service"); +const app = require("../app"); + +describe("group invites routes", () => { + beforeEach(() => { + jest.clearAllMocks(); + invitesService.resolveManagedGroupId.mockResolvedValue(1); + invitesService.listInviteLinks.mockResolvedValue([]); + invitesService.listPendingJoinRequests.mockResolvedValue([]); + invitesService.createInviteLink.mockResolvedValue({ + id: 1, + token: "abcd", + status: "ACTIVE", + }); + invitesService.getInviteLinkSummaryByToken.mockResolvedValue({ + id: 1, + token: "abcd", + group_name: "Test Group", + status: "ACTIVE", + active_policy: "AUTO_ACCEPT", + }); + }); + + test("admin-only checks are enforced on invite management routes", async () => { + invitesService.createInviteLink.mockRejectedValue( + new invitesService.InviteServiceError( + "FORBIDDEN", + "Admin or owner role required", + 403 + ) + ); + + const response = await request(app).post("/api/groups/invites").send({ + policy: "AUTO_ACCEPT", + singleUse: false, + ttlDays: 3, + }); + + expect(response.status).toBe(403); + expect(response.body.error.code).toBe("FORBIDDEN"); + expect(response.body.request_id).toBeTruthy(); + }); + + test("request_id is present in invite responses", async () => { + const response = await request(app).get("/api/invite-links/abcd1234"); + + expect(response.status).toBe(200); + expect(response.body.request_id).toBeTruthy(); + expect(response.body.link).toBeTruthy(); + }); + + test("pending join requests can be listed with request_id", async () => { + invitesService.listPendingJoinRequests.mockResolvedValue([ + { id: 12, user_id: 77, username: "pending-user", status: "PENDING" }, + ]); + + const response = await request(app).get("/api/groups/join-requests"); + + expect(response.status).toBe(200); + expect(response.body.request_id).toBeTruthy(); + expect(response.body.requests).toEqual([ + { id: 12, user_id: 77, username: "pending-user", status: "PENDING" }, + ]); + }); + + test("decision route maps service validation errors", async () => { + invitesService.decideJoinRequest.mockRejectedValue( + new invitesService.InviteServiceError( + "JOIN_REQUEST_NOT_FOUND", + "Pending join request not found", + 404 + ) + ); + + const response = await request(app) + .post("/api/groups/join-requests/decision") + .send({ requestId: 99, decision: "APPROVE" }); + + expect(response.status).toBe(404); + expect(response.body.request_id).toBeTruthy(); + expect(response.body.error.code).toBe("JOIN_REQUEST_NOT_FOUND"); + }); +}); diff --git a/backend/tests/group-invites.service.test.js b/backend/tests/group-invites.service.test.js new file mode 100644 index 0000000..c66d4d7 --- /dev/null +++ b/backend/tests/group-invites.service.test.js @@ -0,0 +1,309 @@ +jest.mock("../models/group-invites.model", () => ({ + addGroupMember: jest.fn(), + createGroupAuditLog: jest.fn(), + createInviteLink: jest.fn(), + createOrTouchPendingJoinRequest: jest.fn(), + consumeSingleUseInvite: jest.fn(), + deleteInviteLink: jest.fn(), + getGroupById: jest.fn(), + getGroupSettings: jest.fn(), + getInviteLinkById: jest.fn(), + getInviteLinkSummaryByToken: jest.fn(), + getManageableGroupsForUser: jest.fn(), + getPendingJoinRequestById: jest.fn(), + getPendingJoinRequest: jest.fn(), + getUserGroupRole: jest.fn(), + isGroupMember: jest.fn(), + listPendingJoinRequests: jest.fn(), + listInviteLinks: jest.fn(), + revokeInviteLink: jest.fn(), + reviveInviteLink: jest.fn(), + updateJoinRequestDecision: jest.fn(), + upsertGroupSettings: jest.fn(), + withTransaction: jest.fn(), +})); + +const invitesModel = require("../models/group-invites.model"); +const invitesService = require("../services/group-invites.service"); + +function inviteSummary(overrides = {}) { + return { + id: 30, + group_id: 10, + group_name: "Test Group", + token: "1234567890abcdef1234567890fedcba", + policy: "AUTO_ACCEPT", + current_join_policy: "AUTO_ACCEPT", + single_use: false, + expires_at: "2030-01-01T00:00:00.000Z", + used_at: null, + revoked_at: null, + ...overrides, + }; +} + +describe("group invites service", () => { + beforeEach(() => { + jest.clearAllMocks(); + invitesModel.withTransaction.mockImplementation(async (handler) => handler({})); + }); + + test("create link success writes audit with request_id and token last4 only", async () => { + invitesModel.getGroupById.mockResolvedValue({ id: 1, name: "G1" }); + invitesModel.getUserGroupRole.mockResolvedValue("admin"); + invitesModel.createInviteLink.mockResolvedValue({ + id: 55, + group_id: 1, + token: "1234567890abcdef1234567890fedcba", + policy: "AUTO_ACCEPT", + single_use: true, + expires_at: "2030-01-01T00:00:00.000Z", + created_at: "2026-01-01T00:00:00.000Z", + }); + + const link = await invitesService.createInviteLink( + 7, + 1, + "AUTO_ACCEPT", + true, + "2030-01-01T00:00:00.000Z", + "req-123", + "127.0.0.1", + "ua" + ); + + expect(link.id).toBe(55); + expect(invitesModel.createGroupAuditLog).toHaveBeenCalledTimes(1); + const auditPayload = invitesModel.createGroupAuditLog.mock.calls[0][0]; + expect(auditPayload.requestId).toBe("req-123"); + expect(auditPayload.metadata).toEqual({ inviteCodeLast4: "dcba" }); + }); + + test("accept auto-accept adds membership", async () => { + invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary()); + invitesModel.isGroupMember.mockResolvedValue(false); + invitesModel.getPendingJoinRequest.mockResolvedValue(null); + invitesModel.getUserGroupRole.mockResolvedValue(null); + invitesModel.addGroupMember.mockResolvedValue(true); + + const result = await invitesService.acceptInviteLink( + 99, + "token-1", + "req-1", + "127.0.0.1", + "ua" + ); + + expect(result).toEqual({ + status: "JOINED", + group: { id: 10, name: "Test Group" }, + }); + expect(invitesModel.addGroupMember).toHaveBeenCalled(); + expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe( + "GROUP_INVITE_USED" + ); + }); + + test("accept manual policy creates pending request", async () => { + invitesModel.getInviteLinkSummaryByToken.mockResolvedValue( + inviteSummary({ + current_join_policy: "APPROVAL_REQUIRED", + single_use: true, + }) + ); + invitesModel.isGroupMember.mockResolvedValue(false); + invitesModel.getPendingJoinRequest.mockResolvedValue(null); + invitesModel.getUserGroupRole.mockResolvedValue(null); + invitesModel.createOrTouchPendingJoinRequest.mockResolvedValue({ + id: 1, + status: "PENDING", + }); + + const result = await invitesService.acceptInviteLink( + 99, + "token-2", + "req-2", + "127.0.0.1", + "ua" + ); + + expect(result).toEqual({ + status: "PENDING", + group: { id: 10, name: "Test Group" }, + }); + expect(invitesModel.createOrTouchPendingJoinRequest).toHaveBeenCalled(); + expect(invitesModel.consumeSingleUseInvite).toHaveBeenCalledWith(30, {}); + expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe( + "GROUP_INVITE_REQUESTED" + ); + }); + + test.each([ + ["INVITE_EXPIRED", inviteSummary({ expires_at: "2020-01-01T00:00:00.000Z" })], + ["INVITE_REVOKED", inviteSummary({ revoked_at: "2026-01-01T00:00:00.000Z" })], + [ + "INVITE_USED", + inviteSummary({ single_use: true, used_at: "2026-01-01T00:00:00.000Z" }), + ], + ])("rejects %s links", async (expectedCode, summary) => { + invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(summary); + invitesModel.isGroupMember.mockResolvedValue(false); + invitesModel.getPendingJoinRequest.mockResolvedValue(null); + + await expect( + invitesService.acceptInviteLink(99, "token-3", "req-3", "127.0.0.1", "ua") + ).rejects.toMatchObject({ code: expectedCode }); + }); + + test("accept returns ALREADY_MEMBER before pending checks", async () => { + invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary()); + invitesModel.isGroupMember.mockResolvedValue(true); + + const result = await invitesService.acceptInviteLink( + 99, + "token-4", + "req-4", + "127.0.0.1", + "ua" + ); + + expect(result.status).toBe("ALREADY_MEMBER"); + expect(invitesModel.getPendingJoinRequest).not.toHaveBeenCalled(); + }); + + test("accept returns PENDING when request already exists", async () => { + invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary()); + invitesModel.isGroupMember.mockResolvedValue(false); + invitesModel.getPendingJoinRequest.mockResolvedValue({ + id: 5, + status: "PENDING", + }); + + const result = await invitesService.acceptInviteLink( + 99, + "token-5", + "req-5", + "127.0.0.1", + "ua" + ); + + expect(result.status).toBe("PENDING"); + expect(invitesModel.addGroupMember).not.toHaveBeenCalled(); + }); + + test("listPendingJoinRequests requires manager role and returns pending requests", async () => { + invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" }); + invitesModel.getUserGroupRole.mockResolvedValue("owner"); + invitesModel.listPendingJoinRequests.mockResolvedValue([ + { id: 12, user_id: 88, username: "pending-user", status: "PENDING" }, + ]); + + const result = await invitesService.listPendingJoinRequests(99, 10); + + expect(invitesModel.listPendingJoinRequests).toHaveBeenCalledWith(10); + expect(result).toEqual([ + { id: 12, user_id: 88, username: "pending-user", status: "PENDING" }, + ]); + }); + + test("approve join request adds membership, updates request, and audits decision", async () => { + invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" }); + invitesModel.getUserGroupRole.mockResolvedValue("admin"); + invitesModel.getPendingJoinRequestById.mockResolvedValue({ + id: 77, + group_id: 10, + user_id: 55, + username: "pending-user", + status: "PENDING", + }); + invitesModel.isGroupMember.mockResolvedValue(false); + invitesModel.addGroupMember.mockResolvedValue(true); + invitesModel.updateJoinRequestDecision.mockResolvedValue({ + id: 77, + group_id: 10, + user_id: 55, + status: "APPROVED", + decided_by: 99, + }); + + const result = await invitesService.decideJoinRequest( + 99, + 10, + 77, + "APPROVE", + "req-approve", + "127.0.0.1", + "ua" + ); + + expect(invitesModel.getPendingJoinRequestById).toHaveBeenCalledWith( + 10, + 77, + {}, + true + ); + expect(invitesModel.addGroupMember).toHaveBeenCalledWith(10, 55, "member", {}); + expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith( + 10, + 77, + "APPROVED", + 99, + {} + ); + expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({ + eventType: "GROUP_JOIN_REQUEST_APPROVED", + requestId: "req-approve", + metadata: { + joinRequestId: 77, + targetUserId: 55, + }, + }); + expect(result.status).toBe("APPROVED"); + }); + + test("deny join request updates request and audits decision", async () => { + invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" }); + invitesModel.getUserGroupRole.mockResolvedValue("owner"); + invitesModel.getPendingJoinRequestById.mockResolvedValue({ + id: 78, + group_id: 10, + user_id: 56, + status: "PENDING", + }); + invitesModel.updateJoinRequestDecision.mockResolvedValue({ + id: 78, + group_id: 10, + user_id: 56, + status: "DENIED", + decided_by: 99, + }); + + const result = await invitesService.decideJoinRequest( + 99, + 10, + 78, + "DENY", + "req-deny", + "127.0.0.1", + "ua" + ); + + expect(invitesModel.addGroupMember).not.toHaveBeenCalled(); + expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith( + 10, + 78, + "DENIED", + 99, + {} + ); + expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({ + eventType: "GROUP_JOIN_REQUEST_DENIED", + requestId: "req-deny", + metadata: { + joinRequestId: 78, + targetUserId: 56, + }, + }); + expect(result.status).toBe("DENIED"); + }); +}); diff --git a/backend/tests/households.controller.test.js b/backend/tests/households.controller.test.js new file mode 100644 index 0000000..6ff47cb --- /dev/null +++ b/backend/tests/households.controller.test.js @@ -0,0 +1,88 @@ +jest.mock("../models/household.model", () => ({ + getUserRole: jest.fn(), + transferOwnership: jest.fn(), + updateMemberRole: jest.fn(), +})); + +jest.mock("../utils/logger", () => ({ + logError: jest.fn(), +})); + +const householdModel = require("../models/household.model"); +const controller = require("../controllers/households.controller"); + +function createResponse() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe("households.controller updateMemberRole", () => { + beforeEach(() => { + jest.clearAllMocks(); + householdModel.getUserRole.mockResolvedValue("member"); + householdModel.transferOwnership.mockResolvedValue({ user_id: 7, role: "owner" }); + householdModel.updateMemberRole.mockResolvedValue({ user_id: 7, role: "admin" }); + }); + + test("owner can transfer household ownership", async () => { + const req = { + params: { householdId: "3", userId: "7" }, + body: { role: "owner" }, + user: { id: 1 }, + household: { id: 3, role: "owner" }, + }; + const res = createResponse(); + + await controller.updateMemberRole(req, res); + + expect(householdModel.transferOwnership).toHaveBeenCalledWith("3", 1, 7); + expect(householdModel.updateMemberRole).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + message: "Household ownership transferred successfully", + member: { user_id: 7, role: "owner" }, + }); + }); + + test("admin cannot transfer household ownership", async () => { + const req = { + params: { householdId: "3", userId: "7" }, + body: { role: "owner" }, + user: { id: 1 }, + household: { id: 3, role: "admin" }, + }; + const res = createResponse(); + + await controller.updateMemberRole(req, res); + + expect(householdModel.transferOwnership).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Only the household owner can transfer ownership", + }), + }) + ); + }); + + test("owner can still update a member to admin without transfer flow", async () => { + const req = { + params: { householdId: "3", userId: "7" }, + body: { role: "admin" }, + user: { id: 1 }, + household: { id: 3, role: "owner" }, + }; + const res = createResponse(); + + await controller.updateMemberRole(req, res); + + expect(householdModel.updateMemberRole).toHaveBeenCalledWith("3", "7", "admin"); + expect(householdModel.transferOwnership).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + message: "Member role updated successfully", + member: { user_id: 7, role: "admin" }, + }); + }); +}); diff --git a/backend/tests/list.model.v2.test.js b/backend/tests/list.model.v2.test.js new file mode 100644 index 0000000..59e4469 --- /dev/null +++ b/backend/tests/list.model.v2.test.js @@ -0,0 +1,136 @@ +jest.mock("../db/pool", () => ({ + query: jest.fn(), +})); + +const pool = require("../db/pool"); +const List = require("../models/list.model.v2"); + +describe("list.model.v2 addOrUpdateItem", () => { + beforeEach(() => { + pool.query.mockReset(); + }); + + test("returns household store item metadata when creating a new list item", async () => { + pool.query + .mockResolvedValueOnce({ rowCount: 0, rows: [] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] }) + .mockResolvedValueOnce({ rowCount: 0, rows: [] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] }); + + const result = await List.addOrUpdateItem(1, 2, "Milk", 3, 7); + + expect(result).toEqual({ + listId: 88, + itemId: 55, + householdStoreItemId: 55, + itemName: "milk", + isNew: true, + }); + expect(pool.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining("FROM household_store_items"), + [1, 2, "milk"] + ); + expect(pool.query).toHaveBeenNthCalledWith( + 2, + expect.stringContaining("INSERT INTO household_store_items"), + [1, 2, "milk", "milk"] + ); + }); + + test("returns household store item metadata when updating an existing list item", async () => { + pool.query + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] }) + .mockResolvedValueOnce({ rowCount: 1, rows: [] }); + + const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7); + + expect(result).toEqual({ + listId: 88, + itemId: 55, + householdStoreItemId: 55, + itemName: "milk", + isNew: false, + }); + expect(pool.query).toHaveBeenNthCalledWith( + 3, + expect.stringContaining("UPDATE household_lists"), + [4, 88] + ); + }); +}); + +describe("list.model.v2 classification helpers", () => { + beforeEach(() => { + pool.query.mockReset(); + }); + + test("gets classification using household, store, and household-store item ids", async () => { + pool.query.mockResolvedValueOnce({ + rowCount: 1, + rows: [ + { + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + confidence: 1, + source: "user", + }, + ], + }); + + const result = await List.getClassification(1, 2, 55); + + expect(result).toEqual({ + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + confidence: 1, + source: "user", + }); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("household_store_item_id = $3"), + [1, 2, 55] + ); + }); + + test("upserts classification using household-store item conflict target", async () => { + pool.query.mockResolvedValueOnce({ + rowCount: 1, + rows: [ + { + household_id: 1, + store_id: 2, + household_store_item_id: 55, + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + confidence: 1, + source: "user", + }, + ], + }); + + const result = await List.upsertClassification(1, 2, 55, { + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + confidence: 1, + source: "user", + }); + + expect(result).toEqual( + expect.objectContaining({ + household_id: 1, + store_id: 2, + household_store_item_id: 55, + item_type: "dairy", + }) + ); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"), + [1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"] + ); + }); +}); diff --git a/backend/tests/lists.controller.v2.test.js b/backend/tests/lists.controller.v2.test.js new file mode 100644 index 0000000..0fcdb03 --- /dev/null +++ b/backend/tests/lists.controller.v2.test.js @@ -0,0 +1,379 @@ +jest.mock("../models/list.model.v2", () => ({ + addHistoryRecord: jest.fn(), + addOrUpdateItem: jest.fn(), + ensureHouseholdStoreItem: jest.fn(), + getItemByName: jest.fn(), + upsertClassification: jest.fn(), +})); + +jest.mock("../models/household.model", () => ({ + isHouseholdMember: jest.fn(), +})); + +jest.mock("../utils/logger", () => ({ + logError: jest.fn(), +})); + +const List = require("../models/list.model.v2"); +const householdModel = require("../models/household.model"); +const controller = require("../controllers/lists.controller.v2"); + +function createResponse() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe("lists.controller.v2 addItem", () => { + beforeEach(() => { + jest.clearAllMocks(); + List.addOrUpdateItem.mockResolvedValue({ + listId: 42, + itemId: 99, + householdStoreItemId: 99, + itemName: "milk", + isNew: true, + }); + List.addHistoryRecord.mockResolvedValue(undefined); + List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" }); + List.upsertClassification.mockResolvedValue(undefined); + householdModel.isHouseholdMember.mockResolvedValue(true); + }); + + test("records history for selected added_for_user_id when member is valid", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1", added_for_user_id: "9" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9); + expect(List.addOrUpdateItem).toHaveBeenCalled(); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9); + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + test("records history using request user when added_for_user_id is not provided", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); + expect(List.addOrUpdateItem).toHaveBeenCalled(); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7); + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + test("records history using request user when added_for_user_id is blank", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1", added_for_user_id: " " }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(householdModel.isHouseholdMember).not.toHaveBeenCalled(); + expect(List.addOrUpdateItem).toHaveBeenCalled(); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7); + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + test("rejects invalid added_for_user_id", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1", added_for_user_id: "abc" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(List.addOrUpdateItem).not.toHaveBeenCalled(); + expect(List.addHistoryRecord).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Added-for user ID must be a positive integer", + }), + }) + ); + }); + + test("rejects malformed numeric-looking added_for_user_id", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1", added_for_user_id: "9abc" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(List.addOrUpdateItem).not.toHaveBeenCalled(); + expect(List.addHistoryRecord).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Added-for user ID must be a positive integer", + }), + }) + ); + }); + + test("rejects added_for_user_id when target user is not household member", async () => { + householdModel.isHouseholdMember.mockResolvedValue(false); + + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1", added_for_user_id: "11" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 11); + expect(List.addOrUpdateItem).not.toHaveBeenCalled(); + expect(List.addHistoryRecord).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Selected user is not a member of this household", + }), + }) + ); + }); +}); + +describe("lists.controller.v2 setClassification", () => { + beforeEach(() => { + jest.clearAllMocks(); + List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" }); + List.upsertClassification.mockResolvedValue(undefined); + List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" }); + }); + + test("accepts object classification with type, group, and zone", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + classification: { + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + }, + }, + user: { id: 7 }, + }; + const res = createResponse(); + + await controller.setClassification(req, res); + + expect(List.upsertClassification).toHaveBeenCalledWith( + "1", + "2", + 99, + expect.objectContaining({ + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + confidence: 1.0, + source: "user", + }) + ); + expect(res.status).not.toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Classification set", + classification: { + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + }, + }) + ); + }); + + test("accepts zone-only classification updates", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + classification: { + zone: "Checkout Area", + }, + }, + user: { id: 7 }, + }; + const res = createResponse(); + + await controller.setClassification(req, res); + + expect(List.upsertClassification).toHaveBeenCalledWith( + "1", + "2", + 99, + expect.objectContaining({ + item_type: null, + item_group: null, + zone: "Checkout Area", + }) + ); + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + test("rejects invalid item_type", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + classification: { + item_type: "invalid-type", + }, + }, + user: { id: 7 }, + }; + const res = createResponse(); + + await controller.setClassification(req, res); + + expect(List.upsertClassification).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Invalid item_type", + }), + }) + ); + }); + + test("rejects invalid item_group for selected item_type", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + classification: { + item_type: "dairy", + item_group: "Bread", + }, + }, + user: { id: 7 }, + }; + const res = createResponse(); + + await controller.setClassification(req, res); + + expect(List.upsertClassification).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Invalid item_group for selected item_type", + }), + }) + ); + }); + + test("rejects invalid zone", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + classification: { + zone: "Space Aisle", + }, + }, + user: { id: 7 }, + }; + const res = createResponse(); + + await controller.setClassification(req, res); + + expect(List.upsertClassification).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Invalid zone", + }), + }) + ); + }); + + test("accepts legacy string classification values", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "milk", + classification: "beverages", + }, + user: { id: 7 }, + }; + const res = createResponse(); + + await controller.setClassification(req, res); + + expect(List.upsertClassification).toHaveBeenCalledWith( + "1", + "2", + 99, + expect.objectContaining({ + item_type: "beverage", + item_group: null, + zone: null, + }) + ); + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + test("creates a household store item when classification target is not yet on the list", async () => { + List.getItemByName.mockResolvedValueOnce(null); + + const req = { + params: { householdId: "1", storeId: "2" }, + body: { + item_name: "granola", + classification: { + zone: "Snacks & Candy", + }, + }, + user: { id: 7 }, + }; + const res = createResponse(); + + await controller.setClassification(req, res); + + expect(List.ensureHouseholdStoreItem).toHaveBeenCalledWith("1", "2", "granola"); + expect(List.upsertClassification).toHaveBeenCalledWith( + "1", + "2", + 99, + expect.objectContaining({ + zone: "Snacks & Candy", + }) + ); + expect(res.status).not.toHaveBeenCalledWith(400); + }); +}); diff --git a/backend/utils/cookies.js b/backend/utils/cookies.js new file mode 100644 index 0000000..359954f --- /dev/null +++ b/backend/utils/cookies.js @@ -0,0 +1,25 @@ +function parseCookieHeader(cookieHeader) { + const cookies = {}; + if (!cookieHeader || typeof cookieHeader !== "string") return cookies; + + const segments = cookieHeader.split(";"); + for (const segment of segments) { + const index = segment.indexOf("="); + if (index === -1) continue; + const key = segment.slice(0, index).trim(); + const value = segment.slice(index + 1).trim(); + if (!key) continue; + try { + cookies[key] = decodeURIComponent(value); + } catch (_) { + // Ignore malformed cookie values instead of throwing. + continue; + } + } + + return cookies; +} + +module.exports = { + parseCookieHeader, +}; diff --git a/backend/utils/http.js b/backend/utils/http.js new file mode 100644 index 0000000..f5e3e57 --- /dev/null +++ b/backend/utils/http.js @@ -0,0 +1,116 @@ +function isPlainObject(value) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +function errorCodeFromStatus(statusCode) { + switch (statusCode) { + case 400: + return "bad_request"; + case 401: + return "unauthorized"; + case 403: + return "forbidden"; + case 404: + return "not_found"; + case 409: + return "conflict"; + case 413: + return "payload_too_large"; + case 415: + return "unsupported_media_type"; + case 422: + return "unprocessable_entity"; + case 429: + return "rate_limited"; + case 500: + return "internal_error"; + default: + return statusCode >= 500 ? "internal_error" : "request_error"; + } +} + +function normalizeErrorPayload(payload, statusCode) { + if (statusCode < 400) return payload; + + if (typeof payload === "string") { + return { + error: { + code: errorCodeFromStatus(statusCode), + message: payload, + }, + }; + } + + if (!isPlainObject(payload)) { + return { + error: { + code: errorCodeFromStatus(statusCode), + message: "Request failed", + }, + }; + } + + if (isPlainObject(payload.error)) { + const code = payload.error.code || errorCodeFromStatus(statusCode); + const message = payload.error.message || "Request failed"; + return { + ...payload, + error: { + ...payload.error, + code, + message, + }, + }; + } + + if (typeof payload.error === "string") { + const { error, ...rest } = payload; + return { + ...rest, + error: { + code: errorCodeFromStatus(statusCode), + message: error, + }, + }; + } + + if (typeof payload.message === "string") { + const { message, ...rest } = payload; + return { + ...rest, + error: { + code: errorCodeFromStatus(statusCode), + message, + }, + }; + } + + return { + ...payload, + error: { + code: errorCodeFromStatus(statusCode), + message: "Request failed", + }, + }; +} + +function sendError(res, statusCode, message, code, extra = {}) { + return res.status(statusCode).json({ + ...extra, + error: { + code: code || errorCodeFromStatus(statusCode), + message, + }, + }); +} + +module.exports = { + errorCodeFromStatus, + normalizeErrorPayload, + sendError, +}; diff --git a/backend/utils/logger.js b/backend/utils/logger.js new file mode 100644 index 0000000..8c9d4a9 --- /dev/null +++ b/backend/utils/logger.js @@ -0,0 +1,20 @@ +const { safeErrorMessage } = require("./redaction"); + +function formatExtra(extra = {}) { + return Object.entries(extra) + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .map(([key, value]) => `${key}=${String(value)}`) + .join(" "); +} + +function logError(req, context, error, extra = {}) { + const requestId = req?.request_id || "unknown"; + const message = safeErrorMessage(error); + const extraText = formatExtra(extra); + const suffix = extraText ? ` ${extraText}` : ""; + console.error(`[${context}] request_id=${requestId} message=${message}${suffix}`); +} + +module.exports = { + logError, +}; diff --git a/backend/utils/redaction.js b/backend/utils/redaction.js new file mode 100644 index 0000000..ee0dd6a --- /dev/null +++ b/backend/utils/redaction.js @@ -0,0 +1,20 @@ +function inviteCodeLast4(inviteCode) { + if (!inviteCode || typeof inviteCode !== "string") return "none"; + const trimmed = inviteCode.trim(); + if (!trimmed) return "none"; + return trimmed.slice(-4); +} + +function safeErrorMessage(error) { + if (!error) return "unknown_error"; + if (typeof error === "string") return error; + if (typeof error.message === "string" && error.message.trim()) { + return error.message; + } + return "unknown_error"; +} + +module.exports = { + inviteCodeLast4, + safeErrorMessage, +}; diff --git a/backend/utils/session-cookie.js b/backend/utils/session-cookie.js new file mode 100644 index 0000000..0cd8dbd --- /dev/null +++ b/backend/utils/session-cookie.js @@ -0,0 +1,36 @@ +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sid"; +const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS || 30); + +function sessionMaxAgeMs() { + return SESSION_TTL_DAYS * 24 * 60 * 60 * 1000; +} + +function cookieName() { + return SESSION_COOKIE_NAME; +} + +function setSessionCookie(res, sessionId) { + res.cookie(cookieName(), sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: sessionMaxAgeMs(), + }); +} + +function clearSessionCookie(res) { + res.clearCookie(cookieName(), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); +} + +module.exports = { + SESSION_TTL_DAYS, + clearSessionCookie, + cookieName, + setSessionCookie, +}; diff --git a/debug.log b/debug.log new file mode 100644 index 0000000..78466d8 --- /dev/null +++ b/debug.log @@ -0,0 +1,6 @@ +[0219/013019.369:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5) +[0219/013019.648:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5) +[0219/013030.696:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5) +[0219/013038.475:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5) +[0219/013103.277:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5) +[0219/014227.547:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5) diff --git a/dev-rebuild.sh b/dev-rebuild.sh index 8bab805..8ba4ebc 100644 --- a/dev-rebuild.sh +++ b/dev-rebuild.sh @@ -1,8 +1,5 @@ #!/bin/bash -# Quick script to rebuild Docker Compose dev environment +set -euo pipefail -echo "Stopping containers and removing volumes..." -docker-compose -f docker-compose.dev.yml down -v - -echo "Rebuilding and starting containers..." -docker-compose -f docker-compose.dev.yml up --build +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/rebuild-dev.sh" "$@" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5982a4b..5501cdc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,7 +9,7 @@ services: - ./frontend:/app - frontend_node_modules:/app/node_modules ports: - - "3000:5173" + - "3010:5173" depends_on: - backend restart: always diff --git a/docker-compose.new.yml b/docker-compose.new.yml new file mode 100644 index 0000000..207fb9f --- /dev/null +++ b/docker-compose.new.yml @@ -0,0 +1,20 @@ +services: + backend: + image: git.nicosaya.com/nalalangan/grocery-app/backend:main-new + # image: grocery-app/backend:main-new + restart: always + env_file: + - ./backend.env + ports: + - "5001:5000" + + frontend: + image: git.nicosaya.com/nalalangan/grocery-app/frontend:main-new + # image: grocery-app/frontend:main-new + restart: always + env_file: + - ./frontend.env + ports: + - "3001:5173" + depends_on: + - backend diff --git a/docker-compose.yml b/docker-compose.yml index 51f003f..4ae6c18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: backend: - image: git.nicosaya.com/nalalangan/costco-grocery-list/backend:latest + image: git.nicosaya.com/nalalangan/grocery-app/backend:latest restart: always env_file: - ./backend.env @@ -8,7 +8,7 @@ services: - "5000:5000" frontend: - image: git.nicosaya.com/nalalangan/costco-grocery-list/frontend:latest + image: git.nicosaya.com/nalalangan/grocery-app/frontend:latest restart: always env_file: - ./frontend.env diff --git a/docs/AGENTIC_CONTRACT_MAP.md b/docs/AGENTIC_CONTRACT_MAP.md new file mode 100644 index 0000000..c27da56 --- /dev/null +++ b/docs/AGENTIC_CONTRACT_MAP.md @@ -0,0 +1,49 @@ +# Agentic Contract Map (Current Stack) + +This file maps `PROJECT_INSTRUCTIONS.md` architecture intent to the current repository stack. + +## Current stack +- Backend: Express (`backend/`) +- Frontend: React + Vite (`frontend/`) + +## Contract mapping + +### API Route Handlers (`app/api/**/route.ts` intent) +Current equivalent: +- `backend/routes/*.js` +- `backend/controllers/*.js` + +Expectation: +- Keep these thin for parsing/validation and response shape. +- Delegate DB and authorization-heavy logic to model/service layers. + +### Server Services (`lib/server/*` intent) +Current equivalent: +- `backend/models/*.js` +- `backend/middleware/*.js` +- `backend/db/*` + +Expectation: +- Concentrate DB access and authorization logic in these backend layers. +- Avoid raw DB usage directly in route files unless no service/model exists. + +### Client Wrappers (`lib/client/*` intent) +Current equivalent: +- `frontend/src/api/*.js` + +Expectation: +- Centralize fetch/axios calls and error normalization here. +- Always send credentials/authorization headers as required. + +### Hooks (`hooks/use-*.ts` intent) +Current equivalent: +- `frontend/src/context/*` +- `frontend/src/utils/*` for route guards + +Expectation: +- Keep components free of direct raw network calls where possible. +- Favor one canonical state propagation mechanism per concern. + +## Notes +- This map does not force a framework migration. +- It defines how to apply the contract consistently in the existing codebase. diff --git a/docs/DB_MIGRATION_WORKFLOW.md b/docs/DB_MIGRATION_WORKFLOW.md new file mode 100644 index 0000000..a4c573b --- /dev/null +++ b/docs/DB_MIGRATION_WORKFLOW.md @@ -0,0 +1,70 @@ +# DB Migration Workflow (External Postgres) + +This project uses an external on-prem Postgres database. Migration files are canonical in: + +- `packages/db/migrations` + +## Preconditions +- `DATABASE_URL` is set and points to the on-prem Postgres instance. +- `psql` is installed and available in PATH. +- You are in repo root. + +## Commands +- Apply pending migrations: + - `npm run db:migrate` +- Show migration status: + - `npm run db:migrate:status` +- Fail if pending migrations exist: + - `npm run db:migrate:verify` +- Create a new migration file: + - `npm run db:migrate:new -- ` +- Track stale legacy SQL in `backend/migrations`: + - `npm run db:migrate:stale` +- Fail when legacy SQL needs operator attention: + - `npm run db:migrate:stale:check` + +## Active migration set +Migration files are applied in lexicographic filename order from `packages/db/migrations`. + +`backend/migrations` is legacy reference-only and not part of canonical execution. +Duplicate reference copies are reported by the stale tracker, but the check fails only +when a legacy SQL file exists only in `backend/migrations` or diverges from its +canonical file. +`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored. + +Current baseline files: +- `add_display_name_column.sql` +- `add_image_columns.sql` +- `add_modified_on_column.sql` +- `add_notes_column.sql` +- `create_item_classification_table.sql` +- `create_sessions_table.sql` +- `multi_household_architecture.sql` + +## Tracking table +Applied migrations are recorded in: + +- `schema_migrations(filename text unique, applied_at timestamptz)` + +## Expected operator flow +1. Check status: + - `npm run db:migrate:status` +2. If a new implementation needs schema changes, create a new file: + - `npm run db:migrate:new -- ` +3. Apply pending: + - `npm run db:migrate` +4. Verify clean state: + - `npm run db:migrate:verify` + +## Troubleshooting +- `DATABASE_URL is required`: + - Export/set `DATABASE_URL` in your environment. +- `psql executable was not found in PATH`: + - Install PostgreSQL client tools and retry. +- SQL failure: + - Fix migration SQL and rerun; only successful files are recorded in `schema_migrations`. +- Skip known stale SQL files for a specific environment: + - Set `DB_MIGRATE_SKIP_FILES` to a comma-separated filename list. + - Example: `DB_MIGRATE_SKIP_FILES=add_modified_on_column.sql,add_image_columns.sql` +- Temporarily include files listed in `stale-files.json`: + - Set `DB_MIGRATE_INCLUDE_STALE=true` before running migration commands. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..2a573d6 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,99 @@ +# Development + +Use this as the practical setup and verification guide. `PROJECT_INSTRUCTIONS.md` remains the source of truth for constraints. + +## Prerequisites +- Node.js 20.19+ or 22.12+ for the current Vite toolchain. +- npm. +- PostgreSQL client tools if running migration scripts (`psql` must be on `PATH`). +- Access to the external Postgres database through `DATABASE_URL` or backend DB variables. +- Docker is optional for local app containers. + +## Install +Run installs separately because this repo does not define npm workspaces. + +```bash +npm ci +npm --prefix backend ci +npm --prefix frontend ci +``` + +## Environment +- Copy `backend/.env.example` to `backend/.env` for backend runtime configuration. +- Copy `frontend/.env.example` to `frontend/.env` if the frontend needs non-default API or host settings. +- Do not commit real `.env` files. +- Root DB migration scripts read `DATABASE_URL` from the shell environment; they do not load `backend/.env` automatically. + +Important variables: + +| Variable | Used by | Notes | +| --- | --- | --- | +| `DATABASE_URL` | backend, root migration scripts | Preferred external Postgres connection string. | +| `DB_USER`, `DB_PASS`, `DB_HOST`, `DB_PORT`, `DB_NAME` | backend fallback | Used only when `DATABASE_URL` is absent. | +| `JWT_SECRET` | backend auth | Required for token/session-compatible auth paths. | +| `ALLOWED_ORIGINS` | backend CORS | Comma-separated allowed frontend origins. | +| `SESSION_COOKIE_NAME`, `SESSION_TTL_DAYS` | backend cookies | Optional; defaults are defined in code. | +| `VITE_API_URL` | frontend | Defaults to `http://localhost:5000`. | +| `VITE_ALLOWED_HOSTS` | Vite dev server | Comma-separated host allowlist. | +| `PLAYWRIGHT_BASE_URL` | Playwright | Defaults to `http://localhost:3010`; the e2e runner starts Vite on this URL. | + +## Run Locally +Docker dev stack: + +```bash +docker compose -f docker-compose.dev.yml up +``` + +Separate terminals: + +```bash +npm run dev:backend +npm run dev:frontend +``` + +Default local endpoints: +- Backend: `http://localhost:5000` +- Frontend through Docker compose port mapping: `http://localhost:3010` +- Frontend direct Vite default: `http://localhost:5173` + +## Verification Commands +Root entry points: + +```bash +npm test +npm run lint +npm run typecheck +npm run audit +npm run build:backend +npm run build:frontend +npm run build +npm run test:e2e +``` + +`npm run test:e2e` uses `frontend/scripts/run-playwright.mjs` to start Vite, run +Playwright, and shut Vite down cleanly. Pass Playwright flags after `--`, for +example `npm run test:e2e -- --reporter=list --workers=1`. + +Migration checks: + +```bash +npm run db:migrate:status +npm run db:migrate:verify +npm run db:migrate:stale:check +``` + +Do not run `npm run db:migrate` against a shared or production database unless that is the explicit operator task. + +## Common Troubleshooting +- `DATABASE_URL is required`: export/set `DATABASE_URL` in the shell before root migration commands. +- `psql executable was not found in PATH`: install PostgreSQL client tools. +- Frontend cannot reach backend: confirm `VITE_API_URL`, backend port `5000`, and backend `ALLOWED_ORIGINS`. +- Playwright starts the frontend but API calls fail: start the backend separately or use the Docker dev stack. +- CORS blocked origin: add the exact frontend origin to `ALLOWED_ORIGINS`. + +## Before Finishing Work +- Re-read `AGENTS.md` and any relevant deeper doc. +- Run the smallest useful checks first. +- For API behavior changes, add/update Jest tests with negative cases. +- For user-facing UI behavior changes, add/update focused Playwright coverage. +- Summarize any command that failed, whether it appears pre-existing, and the unresolved risk. diff --git a/docs/PLANS.md b/docs/PLANS.md new file mode 100644 index 0000000..d975022 --- /dev/null +++ b/docs/PLANS.md @@ -0,0 +1,36 @@ +# Planning Template + +Use this template for multi-step features, refactors, risky bugfixes, or DB work. Keep plans short and update them as evidence changes. + +## Goal +- What user-visible or operator-visible outcome should be true? + +## Context +- Relevant files, routes, data tables, docs, tests, and prior decisions. +- Current behavior and desired behavior. + +## Constraints +- External DB only; migrations go in `packages/db/migrations`. +- No cron, workers, or background jobs. +- RBAC must be enforced server-side. +- No secrets, receipt bytes, or full invite codes in logs. +- Preserve current behavior outside the target area. + +## Milestones +1. Recon and evidence. +2. Minimal design. +3. Implementation slice. +4. Tests and verification. +5. Documentation update. + +## Validation +- Commands to run. +- Manual checks needed. +- Negative cases to cover. + +## Rollback +- Files or migrations that would need reverting. +- Data or operator action needed, if any. + +## Open Questions +- Only questions that materially affect architecture, runtime behavior, public APIs, data storage, security, deployment, package manager, or dependency footprint. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md new file mode 100644 index 0000000..dd2c412 --- /dev/null +++ b/docs/PROJECT_MAP.md @@ -0,0 +1,61 @@ +# Project Map + +This is the fast orientation map for Fiddy. + +## Stack +- Backend: Express 5 on Node 20, CommonJS modules, PostgreSQL via `pg`. +- Frontend: React 19 + Vite, mostly JSX with partial TypeScript. +- Package manager: npm. +- Database: external on-prem Postgres. Migrations are canonical in `packages/db/migrations`. + +## Root +- `PROJECT_INSTRUCTIONS.md`: source-of-truth constraints and delivery contract. +- `AGENTS.md`: concise Codex/human working guide. +- `DEBUGGING_INSTRUCTIONS.md`: required bugfix workflow. +- `package.json`: root DB, test, lint, typecheck, build, and e2e command entry points. +- `docker-compose.dev.yml`: local app containers with backend env loaded from `backend/.env`. +- `.gitea/workflows`: deploy workflows for `main` and `main-new`. +- `.github/copilot-instructions.md`: compatibility shim that points back to root instructions. + +## Backend +- `backend/server.js`: starts the Express app. +- `backend/app.js`: middleware, CORS, route mounting, and error handling. +- `backend/build.js`: copies runtime backend files into `backend/dist` for the existing backend build script. +- `backend/routes`: Express routers. +- `backend/controllers`: request handlers. +- `backend/models`: database query modules. +- `backend/services`: domain service logic where present. +- `backend/middleware`: auth, optional auth, RBAC, request IDs, rate limiting, and image upload processing. +- `backend/utils`: logging, HTTP response helpers, cookies, redaction. +- `backend/tests`: Jest/Supertest tests run from the root Jest config. +- `backend/migrations`: legacy/reference SQL only; do not add new canonical migrations here. + +## Frontend +- `frontend/src/main.tsx`: browser entry point. +- `frontend/src/App.jsx`: top-level routing and providers. +- `frontend/src/config.ts`: API base URL. +- `frontend/src/api`: API wrappers and shared Axios client. +- `frontend/src/context`: app state providers. +- `frontend/src/hooks`: reusable UI-facing hooks. +- `frontend/src/pages`: route-level pages. +- `frontend/src/components`: shared and domain UI components. +- `frontend/src/styles`: global, page, component, and theme CSS. +- `frontend/tests`: Playwright e2e tests. + +## Database and Scripts +- `packages/db/migrations`: canonical SQL migration set. +- `packages/db/migrations/stale-files.json`: known skipped/stale migration filenames. +- `scripts/db-migrate*.js`: migration apply/status/verify/new/stale helpers. +- `docs/DB_MIGRATION_WORKFLOW.md`: operator runbook for DB changes. + +## Documentation +- `docs/DEVELOPMENT.md`: setup, run, verification, and troubleshooting. +- `docs/AGENTIC_CONTRACT_MAP.md`: maps Next.js-oriented architecture language to the current Express/Vite stack. +- `docs/PLANS.md`: lightweight template for multi-step work. +- `docs/architecture`, `docs/features`, `docs/guides`, `docs/migration`: deeper reference docs. +- `docs/archive`: historical implementation notes; useful context, not necessarily current. + +## Known Maintainability Hotspots +- `frontend/src/pages/GroceryList.jsx` is large and should be split only during focused UI work. +- `frontend/src/components/manage/ManageHousehold.jsx`, `backend/services/group-invites.service.js`, and `backend/models/group-invites.model.js` are also large enough to review carefully before editing. +- Some older README/guides may describe pre-session or pre-household behavior; verify against current code and root instructions before relying on them. diff --git a/docs/PROJECT_STATE_AUDIT.md b/docs/PROJECT_STATE_AUDIT.md new file mode 100644 index 0000000..b1ef654 --- /dev/null +++ b/docs/PROJECT_STATE_AUDIT.md @@ -0,0 +1,57 @@ +# Project State Audit - Fiddy + +Snapshot date: 2026-02-16 + +## 1) Confirmed stack and structure +- Backend: Express API in `backend/` with `routes/`, `controllers/`, `models/`, `middleware/`, `utils/`. +- Frontend: React + Vite in `frontend/` with API wrappers in `frontend/src/api`, auth/state in `frontend/src/context`, pages in `frontend/src/pages`. +- DB migrations: canonical folder is `packages/db/migrations`. + +## 2) Governance and agentic setup status +- Present and aligned: + - `PROJECT_INSTRUCTIONS.md` + - `AGENTS.md` + - `DEBUGGING_INSTRUCTIONS.md` + - `docs/DB_MIGRATION_WORKFLOW.md` + - `docs/AGENTIC_CONTRACT_MAP.md` +- Commit discipline added in `PROJECT_INSTRUCTIONS.md` section 12 and being followed with small conventional commits. + +## 3) Current implementation status vs vertical-slice goals +1. DB migrate command + schema: + - Implemented: root scripts `db:migrate`, `db:migrate:status`, `db:migrate:verify`. + - Implemented: migration tracking + runbook. +2. Register/Login/Logout (custom sessions): + - Implemented: DB sessions table migration (`create_sessions_table.sql`). + - Implemented: session model, HttpOnly cookie set/clear, `/auth/logout`, auth middleware fallback to DB session cookie. + - Implemented: frontend credentialed API (`withCredentials`), logout route call. +3. Protected dashboard page: + - Partially implemented via existing `PrivateRoute` token gate. +4. Group create/join + switcher: + - Existing household create/join/switch flow exists but does not yet match all group-policy requirements. +5. Entries CRUD: + - Existing list CRUD exists in legacy and multi-household paths. +6. Receipt upload/download endpoints: + - Not implemented as dedicated receipt domain/endpoints. +7. Settings + Reports: + - Settings page exists; reporting is not fully formalized. + +## 4) Contract gaps and risks +- `DATABASE_URL` is now supported in runtime pool config, but local operator environment still needs this variable configured. +- No automated test suite currently exercises the new auth/session behavior; API behavior is mostly validated by static/lint checks. +- Group policy requirements (owner role, join policy states, invite lifecycle constraints, revive semantics) are not fully implemented. +- No explicit audit log persistence layer verified for invite events/request IDs. +- Encoding cleanliness needs ongoing watch; historical mojibake appears in some UI text/log strings. + +## 5) Recommended next implementation order +1. Finalize auth session contract: + - Add authenticated session introspection endpoint (`/users/me` already exists) to support cookie-only bootstrapping if token absent. + - Update frontend auth bootstrap so protected routes work with DB session cookie as canonical auth. +2. Add explicit API tests (auth + households/list negative cases): + - unauthorized + - not-a-member + - invalid input +3. Implement group-policy requirements incrementally: + - owner role migration + policy enums + - invite policy and immutable settings + - approval-required flow + revive/single-use semantics +4. Add dedicated receipt domain endpoints (metadata list vs byte retrieval split) if the product scope requires the receipt contract verbatim. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..24b6be8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# Documentation Index + +This directory contains practical project documentation. Root-level rules still take precedence: + +1. `../PROJECT_INSTRUCTIONS.md` +2. `../DEBUGGING_INSTRUCTIONS.md` for bugfix work +3. `../AGENTS.md` for Codex and human workflow shortcuts + +## Start Here +- `PROJECT_MAP.md`: quick repo structure and ownership map. +- `DEVELOPMENT.md`: install, run, test, lint, build, and troubleshooting. +- `DB_MIGRATION_WORKFLOW.md`: external Postgres migration runbook. +- `AGENTIC_CONTRACT_MAP.md`: maps source-of-truth architecture language to the current Express/Vite stack. +- `PLANS.md`: lightweight template for multi-step work. + +## Architecture +- `architecture/component-structure.md`: frontend component organization and patterns. +- `architecture/multi-household-architecture-plan.md`: multi-household system planning notes. + +## Features +- `features/classification-implementation.md`: item classification notes. +- `features/image-storage-implementation.md`: image storage and handling notes. + +## Guides +- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs. +- `guides/frontend-readme.md`: frontend development notes. +- `guides/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist. +- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands. + +## Migration +- `migration/MIGRATION_GUIDE.md`: historical multi-household migration instructions. +- `migration/POST_MIGRATION_UPDATES.md`: historical post-migration notes. + +## Archive +Files in `archive/` are historical implementation records. They are useful for context, but they are not guaranteed to describe current behavior. + +## Documentation Rules +- Keep docs concise and linked to real files or commands. +- Prefer updating `PROJECT_MAP.md` and `DEVELOPMENT.md` before adding new top-level docs. +- Move completed implementation narratives to `archive/` when they are no longer active runbooks. +- Keep text encoding clean. diff --git a/docs/component-structure.md b/docs/architecture/component-structure.md similarity index 100% rename from docs/component-structure.md rename to docs/architecture/component-structure.md diff --git a/docs/architecture/multi-household-architecture-plan.md b/docs/architecture/multi-household-architecture-plan.md new file mode 100644 index 0000000..0a0f887 --- /dev/null +++ b/docs/architecture/multi-household-architecture-plan.md @@ -0,0 +1,865 @@ +# Multi-Household & Multi-Store Architecture Plan + +## Executive Summary + +This document outlines the architecture and implementation strategy for extending the application to support: +1. **Multiple Households** - Users can belong to multiple households (families, roommates, etc.) +2. **Multiple Stores** - Households can manage lists for different store types (Costco, Target, Walmart, etc.) + +## Current Architecture Analysis + +### Existing Schema +```sql +users (id, username, password, name, role, display_name) +grocery_list (id, item_name, quantity, bought, item_image, image_mime_type, added_by, modified_on) +grocery_history (id, list_item_id, quantity, added_by, added_on) +item_classification (id, item_type, item_group, zone, confidence, source) +``` + +### Current Limitations +- **Single global list** - All users share one grocery list +- **No household concept** - Cannot separate different families' items +- **Store-specific zones** - Classification system assumes Costco layout +- **Single-level roles** - User has same role everywhere (cannot be admin in one household, viewer in another) + +--- + +## Design Considerations & Trade-offs + +### Key Questions to Resolve + +#### 1. Item Management Strategy + +**Option A: Shared Item Master (Recommended)** +- ✅ **Pro**: Single source of truth for item definitions (name, default image, common classification) +- ✅ **Pro**: Consistent item naming across households +- ✅ **Pro**: Can build item recommendation system across all households +- ✅ **Pro**: Easier to implement smart features (price tracking, common items) +- ❌ **Con**: Requires careful privacy controls (who can see which items) +- ❌ **Con**: Different households may classify items differently + +**Option B: Per-Household Items** +- ✅ **Pro**: Complete household isolation +- ✅ **Pro**: Each household fully controls item definitions +- ✅ **Pro**: No privacy concerns about item names +- ❌ **Con**: Duplicate data across households +- ❌ **Con**: Cannot leverage cross-household intelligence +- ❌ **Con**: More complex to implement suggestions + +**Option C: Hybrid Approach (RECOMMENDED)** +- ✅ **Pro**: Best of both worlds +- ✅ **Pro**: Shared item catalog with household-specific classifications +- ✅ **Pro**: Privacy-preserving (only households share item usage, not personal data) +- **How it works**: + - Global `items` table (id, name, default_image, created_at) + - Household-specific `household_list` table references item + household + - Each household can override classifications per store + +--- + +## Proposed Schema Design + +### New Tables + +```sql +-- Households (e.g., "Smith Family", "Apartment 5B") +CREATE TABLE households ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + created_by INTEGER REFERENCES users(id), + invite_code VARCHAR(20) UNIQUE NOT NULL, -- Random code for inviting users + code_expires_at TIMESTAMP -- Optional expiration +); + +-- Store Types (e.g., "Costco", "Target", "Walmart") +CREATE TABLE stores ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + default_zones JSONB, -- Store-specific zone layout + created_at TIMESTAMP DEFAULT NOW() +); + +-- User-Household Membership with per-household roles +CREATE TABLE household_members ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')), + joined_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, user_id) +); + +-- Household-Store Relationship (which stores does this household shop at?) +CREATE TABLE household_stores ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + is_default BOOLEAN DEFAULT FALSE, -- Default store for this household + UNIQUE(household_id, store_id) +); + +-- Master Item Catalog (shared across all households) +CREATE TABLE items ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + default_image BYTEA, + default_image_mime_type VARCHAR(50), + created_at TIMESTAMP DEFAULT NOW(), + usage_count INTEGER DEFAULT 0 -- For popularity tracking +); + +-- Household-specific grocery lists (per store) +CREATE TABLE household_lists ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + item_id INTEGER REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL DEFAULT 1, + bought BOOLEAN DEFAULT FALSE, + custom_image BYTEA, -- Household can override item image + custom_image_mime_type VARCHAR(50), + added_by INTEGER REFERENCES users(id), + modified_on TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id, item_id) -- One item per household+store combo +); + +-- Household-specific item classifications (per store) +CREATE TABLE household_item_classifications ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + item_id INTEGER REFERENCES items(id) ON DELETE CASCADE, + item_type VARCHAR(50), + item_group VARCHAR(100), + zone VARCHAR(100), + confidence DECIMAL(3,2) DEFAULT 1.0, + source VARCHAR(20) DEFAULT 'user', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id, item_id) +); + +-- History tracking (who added what, when, to which household+store list) +CREATE TABLE household_list_history ( + id SERIAL PRIMARY KEY, + household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL, + added_by INTEGER REFERENCES users(id), + added_on TIMESTAMP DEFAULT NOW() +); +``` + +### Indexes for Performance + +```sql +-- Household member lookups +CREATE INDEX idx_household_members_user ON household_members(user_id); +CREATE INDEX idx_household_members_household ON household_members(household_id); + +-- List queries (most common operations) +CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id); +CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought); + +-- Item search +CREATE INDEX idx_items_name ON items(name); +CREATE INDEX idx_items_usage_count ON items(usage_count DESC); + +-- Classification lookups +CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id); +``` + +--- + +## Role System Redesign + +### Dual-Role Hierarchy: System-Wide + Household-Scoped + +```typescript +// System-wide roles (app administration) +users { + id, username, password, name, display_name, + role: 'system_admin' | 'user' // Kept for app-wide controls +} + +// Household-scoped roles (per-household permissions) +household_members { + household_id, user_id, + role: 'admin' | 'user' +} +``` + +### System-Wide Role Definitions + +| Role | Permissions | +|------|-------------| +| **system_admin** | Create/delete stores globally, view all households (moderation), manage global item catalog, access system metrics, promote users to system_admin | +| **user** | Standard user - can create households, join households via invite, manage own profile | + +### Household-Scoped Role Definitions + +| Role | Permissions | +|------|-------------| +| **admin** | Full household control: delete household, invite/remove members, change member roles, manage stores, add/edit/delete items, mark bought, upload images, update classifications | +| **user** | Standard member: add/edit/delete items, mark bought, upload images, update classifications, view all lists | + +### Role Transition Plan + +**Migration Strategy:** +1. Create default household "Main Household" +2. Migrate all existing users → household_members (old admins become household admins, others become users) +3. Keep existing `users.role` column, update values: + - `admin` → `system_admin` (app-wide admin) + - `editor` → `user` (standard user) + - `viewer` → `user` (standard user) +4. Migrate grocery_list → household_lists (all to default household + default store) +5. Migrate item_classification → household_item_classifications + +--- +, systemRole } // System-wide role +req.household = { id, name, role } // Household-scoped role +req.store = { id, name } // Active store context +### Authentication Context + +**Before:** +```javascript +req.user = { id, username, role } +``` + +**After:** +```javascript +req.user = { id, username } +req.household = { id, name, role } // Set by household middleware +req.store = { id, name } // Set by store middleware +``` + +### Middleware Chain with systemRole) +router.use(auth); + +// 2. Household middleware (validates household access, sets req.household with householdRole) +router.use('/households/:householdId', householdAccess); + +// 3. Household role middleware (checks household-scoped permissions) +router.post('/add', requireHouseholdRole(['user', 'admin']), controller.addItem); + +// 4. Admin-only household operations +router.delete('/:id', requireHouseholdRole(['admin']), controller.deleteHousehold); + +// 5. System admin middleware (for app-wide operations) +router.post('/stores', requireSystemRole('system_admin'), controller.createStore + +// 3. Role middleware (checks household-specific role) +rouSystem Administration (system_admin only) +GET /api/admin/stores // Manage all stores +POST /api/admin/stores // Create new store type +PATCH /api/admin/stores/:id // Update store +DELETE /api/admin/stores/:id // Delete store (if unused) +GET /api/admin/households // View all households (moderation) +GET /api/admin/items // Manage global item catalog +GET /api/admin/metrics // System-wide analytics + +// Household Management (any user can create) +GET /api/households // Get all households user belongs to +POST /api/households // Create new household (any user) +GET /api/households/:id // Get household details +PATCH /api/households/:id // Update household (admin only) +DELETE /api/households/:id // Delete household (admin only) + +// Household Members +GET /api/households/:id/members // List members (all roles) +POST /api/households/:id/invite // Generate/refresh invite code (admin only) +POST /api/households/join/:inviteCode // Join household via invite code (joins as 'user') +PATCH /api/households/:id/members/:userId // Update member role (admin only) +DELETE /api/households/:id/members/:userId // Remove member (admin only, or self) + +// Store Management +GET /api/stores // Get all available store types +GET /api/households/:id/stores // Get stores for household +POST /api/households/:id/stores // Add store to household (admin only) +DELETE /api/households/:id/stores/:storeId // Remove store from household (admin only) +// Store Management +GET /api/stores // Get all available stores +POST /api/stores // Create custom store (system admin) +GET /api/households/:id/stores // Get stores for household +POST /api/households/:id/stores // Add store to household (admin+) +DELETE /api/households/:id/stores/:storeId // Remove store (admin+) + +// List Operations (now scoped to household + store) +GET /api/households/:hId/stores/:sId/list // Get list +POST /api/households/:hId/stores/:sId/list/add // Add item +PATCH /api/households/:hId/stores/:sId/list/:itemId // Update item +DELETE /api/households/:hId/stores/:sId/list/:itemId // Delete item +POST /api/households/:hId/stores/:sId/list/:itemId/buy // Mark bought + +// Item Suggestions (across user's households) +GET /api/items/suggestions?q=milk // Search master catalog + +// Classifications (per household + store) +GET /api/households/:hId/stores/:sId/classifications/:itemId +POST /api/households/:hId/stores/:sId/classifications/:itemId +``` + +--- + +## React Context Refactoring Pattern + +### Current Pattern (To Be Replaced) + +```jsx +// Bad: Context is exported, consumers use it directly +export const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + + return ( + + {children} + + ); +} + +// Consumer must import context and useContext +import { useContext } from 'react'; +import { AuthContext } from '../context/AuthContext'; + +function MyComponent() { + const { user, setUser } = useContext(AuthContext); + // ... +} +``` + +### New Pattern (Best Practice) + +```jsx +// Good: Context is internal, custom hook is exported +const AuthContext = createContext(null); // Not exported! + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + + const login = (userData, authToken) => { + setUser(userData); + setToken(authToken); + }; + + const logout = () => { + setUser(null); + setToken(null); + }; + + return ( + + {children} + + ); +} + +// Export custom hook instead +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +} + +// Consumer usage - clean and simple +import { useAuth } from '../context/AuthContext'; + +function MyComponent() { + const { user, login, logout } = useAuth(); + // ... +} +``` + +### Benefits + +1. **Encapsulation** - Context implementation is hidden, only the hook is public API +2. **Type Safety** - Can add TypeScript types to the hook return value +3. **Validation** - Hook can check if used within provider (prevents null errors) +4. **Cleaner Imports** - One import instead of two (`useContext` + `Context`) +5. **Easier Refactoring** - Can change context internals without affecting consumers +6. **Standard Pattern** - Aligns with React best practices and popular libraries + +### Implementation Plan + +**Existing Contexts to Refactor:** +- `AuthContext` → `useAuth()` +- `SettingsContext` → `useSettings()` +- `ConfigContext` → `useConfig()` (if still used) + +**New Contexts to Create:** +- `HouseholdContext` → `useHousehold()` +- `StoreContext` → `useStore()` + +**Migration Steps:** +1. Keep old context export temporarily +2. Add custom hook export +3. Update all components to use hook +4. Remove old context export +5. Make context `const` internal to file + +--- + +## Frontend Architecture Changes + +### Context Structure + +```typescript +// AuthContext - User identity +{ + user: { id, username, display_name, systemRole }, + token: string, + login, logout, + isSystemAdmin: boolean // Computed from systemRole +} + +// HouseholdContext - Active household + household role +{ + activeHousehold: { id, name, role }, // role is 'admin' or 'user' + households: Household[], + switchHousehold: (id) => void, + createHousehold: (name) => void, + joinHousehold: (code) => void, + isAdmin: boolean // Computed helper: role === 'admin' +} + +// StoreContext - Active store +{ + activeStore: { id, name }, + householdStores: Store[], + allStores: Store[], // Available store types (for adding) + switchStore: (id) => void, + addStore: (storeId) => void // Admin+ onlyme, role }, + households: Household[], + switchHousehold: (id) => void, + createHousehold: (name) => void, + joinHousehold: (code) => void +} + +// StoreContext - Active store +{ +/admin → System admin panel (system_admin only) +/admin/stores → Manage store types +/admin/households → View all households +/admin/items → Global item catalog + activeStore: { id, name }, + householdStores: Store[], + switchStore: (id) => void +} (Owner) + + + + {user.systemRole === 'system_admin' && ( + + )} + +``` + +**Store Tabs** (Within Household) +```tsx + + Costco + Target + Walmart + {(isAdmin || isOwner) && + Add Store} → User settings (personal) +``` + +### UI Components + +**Household Switcher** (Navbar) +```tsx + + + + + + +``` + +**Store Tabs** (Within Household) +```tsx + + Costco + Target + Walmart + + Add Store + +``` + +--- + +## Migration Strategy + +### Phase 1: Database Schema (Breaking Change) + +**Step 1: Backup** +```bash +pg_dump grocery_list > backup_$(date +%Y%m%d).sql +``` + +**Step 2: Run Migrations** +```sql +-- 1. Create new tables +CREATE TABLE households (...); +CREATE TABLE household_members (...); +-- ... (all new tables) + +-- 2. Create default household +INSERT INTO households (name, created_by, invite_code) +VALUES ('Main Household', 1, 'DEFAULT123'); + +-- 3. Migrate users → household_members +INSERT INTO household_members (household_id, user_id, role) +SELECT 1, id, + CASE + WHEN role = 'admin' THEN 'admin' -- Old admins become household admins + ELSE 'user' -- Everyone else becomes standard user + END +FROM users; + +-- 4. Create default store +INSERT INTO stores (name, default_zones) +VALUES ('Costco', '{"zones": [...]}'); + +-- 5. Link household to store +INSERT INTO household_stores (household_id, store_id, is_default) +VALUES (1, 1, TRUE); + +-- 6. Migrate items +INSERT INTO items (name, default_image, default_image_mime_type) +SELECT DISTINCT item_name, item_image, image_mime_type +FROM grocery_list; + +-- 7. Migrate grocery_list → household_lists +INSERT INTO household_lists (household_id, store_id, item_id, quantity, bought, added_by, modified_on) +SELECT + 1, -- default household + 1, -- default store + i.id, + gl.quantity, + gl.bought, + gl.added_by, + gl.modified_on +FROM grocery_list gl +JOIN items i ON LOWER(i.name) = LOWER(gl.item_name); + +-- 8. Migrate classifications +INSERT INTO household_item_classifications + (household_id, store_id, item_id, item_type, item_group, zone, confidence, source) +SELECT + 1, 1, i.id, + ic.item_type, ic.item_group, ic.zone, ic.confidence, ic.source +FROM item_classification ic +JOIN grUpdate system roles (keep role column) +UPDATE users SET role = 'system_admin' WHERE role = 'admin'; +UPDATE users SET role = 'user' WHERE role IN ('editor', 'viewer'); + +-- 11. Drop old tables (after verification!) +-- DROP TABLE grocery_history; +-- DROP TABLE item_classification; +-- DROP TABLE grocery_listousehold_list_id, quantity, added_by, added_on) +SELECT hl.id, gh.quantity, gh.added_by, gh.added_on +FROM grocery_history gh +JOIN grocery_list gl ON gh.list_item_id = gl.id +JOIN items i ON LOWER(i.name) = LOWER(gl.item_name) +JOIN household_lists hl ON hl.item_id = i.id AND hl.household_id = 1 AND hl.store_id = 1; + +-- 10. Drop old tables (after verification!) +-- DROP TABLE grocery_history; +-- DROP TABLE item_classification; +-- DROP TABLE grocery_list; +-- ALTER TABLE users DROP COLUMN role; +``` + +### Phase 2: Backend API (Incremental) + +1. ✅ Create new models (households, stores, household_lists) +2. ✅ Create new middleware (householdAccess, storeAccess) +3. ✅ Create new controllers (households, stores) +4. ✅ Add new routes alongside old ones +5. ✅ Update list controllers to be household+store aware +6. ✅ Deprecate old routes (return 410 Gone) + +### Phase 3: Frontend UI (Incremental) + +1. ✅ **Refactor Context Pattern** (applies to all contexts) + - Move `createContext` inside component files (not exported) + - Export custom hooks instead: `useAuth()`, `useHousehold()`, `useStore()`, `useSettings()` + - Consumers use hooks directly instead of `useContext(ExportedContext)` +2. ✅ Create HouseholdContext with `useHousehold()` hook +3. ✅ Create StoreContext with `useStore()` hook +4. ✅ Refactor existing AuthContext to use custom `useAuth()` hook +5. ✅ Refactor existing SettingsContext to use custom `useSettings()` hook +6. ✅ Add household switcher to navbar +7. ✅ Create household management pages +8. ✅ Add store tabs to list view +9. ✅ Update all API calls to use household + store IDs +7. ✅ Add invite system UI +8. ✅ Update settings page to show household-specific settings + +--- + +## Advanced Features (Future) + +### 1. Item Sharing & Privacy + +**Levels:** +- **Private**: Only visible to your household +- **Public**: Available in global item catalog +- **Suggested**: Anonymously contribute to shared catalog + +### 2. Smart Features + +**Cross-Household Intelligence:** +- "10,000 households buy milk at Costco" → suggest classification +- "Items commonly bought together" +- Price tracking across stores +- Store-specific suggestions + +**Household Patterns:** +- "You usually buy milk every 5 days" +- "Bananas are typically added by [User]" +- Auto-add recurring items + +### 3. Multi-Store Optimization + +**Store Comparison:** +- Track which items each household buys at which store +- "This item is 20% cheaper at Target" +- Generate shopping lists across stores + +**Route Optimization:** +- Sort list by store zone +- "You can save 15 minutes by shopping in this order" + +### 4. Enhanced Collaboration + +**Shopping Mode:** +- Real-time collaboration (one person shops, another adds from home) +- Live updates via WebSockets +- "John is currently at Costco (aisle 12)" + +**Shopping Lists:** +- Pre-planned lists (weekly meal prep) +- Recurring lists (monthly bulk buy) +- Shared templates between households + +--- + +## Implementation Timeline + +### Sprint 1: Foundation (2-3 weeks) +- [ ] Design finalization & review +- [ ] Create migration scripts +- [ ] Implement new database tables +- [ ] Test migration on staging data +- [ ] Create new models (household, store, household_list) + +### Sprint 2: Backend API (2-3 weeks) +- [ ] Implement household management endpoints +- [ ] Implement store management endpoints +- [ ] Update list endpoints for household+store scope +- [ ] Create new middleware (householdAccess, storeAccess) +- [ ] Update authentication to remove global role + +### Sprint 3: Frontend Core (2-3 weeks) +- [ ] **Refactor Context Pattern** (foundational change): + - [ ] Refactor AuthContext to internal context + `useAuth()` hook + - [ ] Refactor SettingsContext to internal context + `useSettings()` hook + - [ ] Update all components using old context pattern +- [ ] Create HouseholdContext with `useHousehold()` hook +- [ ] Create StoreContext with `useStore()` hook +- [ ] Build household switcher UI +- [ ] Build store tabs UI +- [ ] Update GroceryList page for new API +- [ ] Create household management pages + +### Sprint 4: Member Management (1-2 weeks) +- [ ] Implement invite code system +- [ ] Build member management UI +- [ ] Implement role updates +- [ ] Add join household flow + +### Sprint 5: Polish & Testing (1-2 weeks) +- [ ] End-to-end testing +- [ ] Performance optimization +- [ ] Mobile responsiveness +- [ ] Documentation updates +- [ ] Migration dry-run on production backup + +### Sprint 6: Production Migration (1 week) +- [ ] Announce maintenance window +- [ ] Run migration on production +- [ ] Verify data integrity +- [ ] Deploy new frontend +- [ ] Monitor for issues + +**Total: 9-14 weeks** + +--- + +## Risk Assessment & Mitigation + +### High Risk Areas + +1. **Data Loss During Migration** + - **Mitigation**: Full backup, dry-run on production copy, rollback plan + +2. **Breaking Existing Users** + - **Mitigation**: Default household preserves current behavior, phased rollout + +3. **Performance Degradation** + - **Mitigation**: Proper indexing, query optimization, caching strategy + +4. **Complexity Creep** + - **Mitigation**: MVP first (basic households), iterate based on feedback + +### Testing Strategy + +1. **Unit Tests**: All new models and controllers +2. **Integration Tests**: API endpoint flows +3. **Migration Tests**: Verify data integrity post-migration +4. **Load Tests**: Multi-household concurrent access +5. **User Acceptance**: Beta test with small group before full rollout + +--- + +## Open Questions & Decisions Needed + +### 1. Item Naming Strategy +- **Question**: Should "milk" from Household A and "Milk" from Household B be the same item? +- **Options**: + - Case-insensitive merge (current behavior, recommended) + - Exact match only + - User prompt for merge confirmation +- **Recommendation**: Case-insensitive with optional household override + +### 2. Store Management +- **Question**: Should all stores be predefined, or can users create custom stores? +- **Options**: + - Admin-only store creation (controlled list) + - Users can create custom stores (flexible but messy) + - Hybrid: predefined + custom +- **Recommendation**: Start with predefined stores, add custom later + +### 3. Historical Data +- **Question**: When a user leaves a household, what happens to their history? +- **Options**: + - Keep history, anonymize user + - Keep history with user name (allows recovery if re-added) + - Delete history +- **Recommendation**: Keep history with actual user name preserved + - **Rationale**: If user is accidentally removed, their contributions remain attributed correctly when re-added + - History queries should JOIN with users table but handle missing users gracefully + - Display format: Show user name if still exists, otherwise show "User [id]" or handle as deleted account + +### 4. Invite System +- **Question**: Should invite codes expire? +- **Options**: + - Never expire (simpler) + - 7-day expiration (more secure) + - Configurable per household +- **Recommendation**: Optional expiration, default to never + +### 5. Default Household +- **Question**: When user logs in, which household/store do they see? +- **Options**: + - Last used (remember preference) + - Most recently modified list + - User-configured default +- **Recommendation**: Remember last used in localStorage + +--- + +## Summary & Next Steps + +### Recommended Approach: **Hybrid Multi-Tenant Architecture** + +**Core Principles:** +1. ✅ Shared item catalog with household-specific lists +2. ✅ Per-household roles (not global) +3. ✅ Store-specific classifications +4. ✅ Invite-based household joining +5. ✅ Backward-compatible migration + +### Immediate Actions + +1. **Review & Approve**: Get stakeholder buy-in on this architecture +2. **Validate Assumptions**: Confirm design decisions (item sharing, store management) +3. **Create Detailed Tickets**: Break down sprints into individual tasks +4. **Set Up Staging**: Create test environment with production data copy +5. **Begin Sprint 1**: Start with database design and migration scripts + +### Success Metrics + +- ✅ Zero data loss during migration +- ✅ 100% existing users migrated to default household +- ✅ Performance within 20% of current (queries < 200ms) +- ✅ Users can create households and invite others +- ✅ Lists properly isolated between households +- ✅ Mobile UI remains responsive + +--- + +## Appendix A: Example User Flows + +### Creating a Household +1. User clicks "Create Household" +2. Enters name "Smith Family" +3. System generates invite code "SMITH2026" +4. User is set as "admin" role (creator is always admin) +5. User can share code with family members + +### Joining a Household +1. User receives invite code "SMITH2026" +2. Navigates to /join/SMITH2026 +3. Sees "Join Smith Family?" +4. Confirms, added as "user" role by default +5. Admin can promote to "admin" role if needed + +### Managing Multiple Households +1. User belongs to "Smith Family" and "Work Team" +2. Navbar shows dropdown: [Smith Family ▼] +3. Clicks dropdown, sees both households +4. Switches to "Work Team" +5. List updates to show Work Team's items +6. Store tabs show Work Team's configured stores + +### Adding Item to Store +1. User in "Smith Family" household +2. Sees store tabs: [Costco] [Target] +3. Clicks "Costco" tab +4. Adds "Milk" - goes to Costco list +5. Switches to "Target" tab +6. Adds "Bread" - goes to Target list +7. Milk and Bread are separate list entries (same item, different stores) + +--- + +## Appendix B: Database Size Estimates + +**Current Single List:** +- Users: 10 +- Items: 200 +- History records: 5,000 + +**After Multi-Household (10 households, 5 stores each):** +- Users: 10 +- Households: 10 +- Household_members: 30 (avg 3 users per household) +- Stores: 5 +- Household_stores: 50 +- Items: 500 (some shared, some unique) +- Household_lists: 2,500 (500 items × 5 stores) +- History: 25,000 + +**Storage Impact:** ~5x increase in list records, but items are deduplicated. + +**Query Performance:** +- Without indexes: O(n) → O(10n) = 10x slower +- With indexes: O(log n) → O(log 10n) = minimal impact + +**Conclusion:** With proper indexing, performance should remain acceptable even at 100+ households. diff --git a/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md b/docs/archive/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md similarity index 100% rename from ACCOUNT_MANAGEMENT_IMPLEMENTATION.md rename to docs/archive/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md diff --git a/docs/archive/HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md b/docs/archive/HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md new file mode 100644 index 0000000..09c5d90 --- /dev/null +++ b/docs/archive/HOUSEHOLD_MANAGEMENT_IMPLEMENTATION.md @@ -0,0 +1,241 @@ +# Household & Store Management - Implementation Summary + +## Overview +Built comprehensive household and store management UI for the multi-household grocery list application. Users can now fully manage their households, members, and stores through a polished interface. + +## Features Implemented + +### 1. Manage Page (`/manage`) +**Location**: [frontend/src/pages/Manage.jsx](frontend/src/pages/Manage.jsx) + +- Tab-based interface for Household and Store management +- Context-aware - always operates on the active household +- Accessible via "Manage" link in the navbar + +### 2. Household Management +**Component**: [frontend/src/components/manage/ManageHousehold.jsx](frontend/src/components/manage/ManageHousehold.jsx) + +**Features**: +- **Edit Household Name**: Admin-only, inline editing +- **Invite Code Management**: + - Show/hide invite code with copy-to-clipboard + - Generate new invite code (invalidates old one) + - Admin-only access +- **Member Management**: + - View all household members with roles + - Promote/demote members between admin and member roles + - Remove members from household + - Cannot remove yourself + - Admin-only actions +- **Delete Household**: + - Admin-only + - Double confirmation required + - Permanently deletes all data + +**Permissions**: +- Viewers: Can only see household name and members +- Members: Same as viewers +- Admins: Full access to all features + +### 3. Store Management +**Component**: [frontend/src/components/manage/ManageStores.jsx](frontend/src/components/manage/ManageStores.jsx) + +**Features**: +- **View Household Stores**: + - Grid layout showing all stores + - Shows store name, location, and default status +- **Add Stores**: + - Select from system-wide store catalog + - Admin-only + - Cannot add already-linked stores +- **Remove Stores**: + - Admin-only + - Cannot remove last store (validation) +- **Set Default Store**: + - Admin-only + - Default store loads automatically + +**Permissions**: +- Viewers & Members: Read-only view of stores +- Admins: Full CRUD operations + +### 4. Create/Join Household Modal +**Component**: [frontend/src/components/manage/CreateJoinHousehold.jsx](frontend/src/components/manage/CreateJoinHousehold.jsx) + +**Features**: +- Tabbed interface: "Create New" or "Join Existing" +- **Create Mode**: + - Enter household name + - Auto-generates invite code + - Creates household with user as admin +- **Join Mode**: + - Enter invite code + - Validates code and adds user as member + - Error handling for invalid codes + +**Access**: +- Available from household switcher dropdown +- "+ Create or Join Household" button at bottom +- All authenticated users can access + +### 5. Updated Household Switcher +**Component**: [frontend/src/components/household/HouseholdSwitcher.jsx](frontend/src/components/household/HouseholdSwitcher.jsx) + +**Enhancements**: +- Added divider between household list and actions +- "+ Create or Join Household" button +- Opens CreateJoinHousehold modal + +## Styling + +### CSS Files Created +1. **[frontend/src/styles/pages/Manage.css](frontend/src/styles/pages/Manage.css)** + - Page layout and tab navigation + - Responsive design + +2. **[frontend/src/styles/components/manage/ManageHousehold.css](frontend/src/styles/components/manage/ManageHousehold.css)** + - Section cards with proper spacing + - Member cards with role badges + - Invite code display + - Danger zone styling + - Button styles (primary, secondary, danger) + +3. **[frontend/src/styles/components/manage/ManageStores.css](frontend/src/styles/components/manage/ManageStores.css)** + - Grid layout for store cards + - Default badge styling + - Add store panel + - Available stores grid + +4. **[frontend/src/styles/components/manage/CreateJoinHousehold.css](frontend/src/styles/components/manage/CreateJoinHousehold.css)** + - Modal overlay and container + - Mode tabs styling + - Form inputs and buttons + - Error message styling + +### Theme Updates +**[frontend/src/styles/theme.css](frontend/src/styles/theme.css)** + +Added simplified CSS variable aliases: +```css +--primary: var(--color-primary); +--primary-dark: var(--color-primary-dark); +--primary-light: var(--color-primary-light); +--danger: var(--color-danger); +--danger-dark: var(--color-danger-hover); +--text-primary: var(--color-text-primary); +--text-secondary: var(--color-text-secondary); +--background: var(--color-bg-body); +--border: var(--color-border-light); +--card-hover: var(--color-bg-hover); +``` + +## Backend Endpoints Used + +All endpoints already existed - no backend changes required! + +### Household Endpoints +- `GET /households` - Get user's households +- `POST /households` - Create household +- `PATCH /households/:id` - Update household name +- `DELETE /households/:id` - Delete household +- `POST /households/:id/invite/refresh` - Refresh invite code +- `POST /households/join/:inviteCode` - Join via invite code +- `GET /households/:id/members` - Get members +- `PATCH /households/:id/members/:userId/role` - Update member role +- `DELETE /households/:id/members/:userId` - Remove member + +### Store Endpoints +- `GET /stores` - Get all stores +- `GET /stores/household/:householdId` - Get household stores +- `POST /stores/household/:householdId` - Add store to household +- `DELETE /stores/household/:householdId/:storeId` - Remove store +- `PATCH /stores/household/:householdId/:storeId/default` - Set default + +## User Flow + +### Managing Household +1. Click "Manage" in navbar +2. View household overview (name, members, invite code) +3. As admin: + - Edit household name + - Generate new invite codes + - Promote/demote members + - Remove members + - Delete household (danger zone) + +### Managing Stores +1. Click "Manage" in navbar +2. Click "Stores" tab +3. View all linked stores with default badge +4. As admin: + - Click "+ Add Store" to see available stores + - Click "Add" on any unlinked store + - Click "Set as Default" on non-default stores + - Click "Remove" to unlink store (except last one) + +### Creating/Joining Household +1. Click household name in navbar +2. Click "+ Create or Join Household" at bottom of dropdown +3. Select "Create New" or "Join Existing" tab +4. Fill form and submit +5. New household appears in list and becomes active + +## Responsive Design + +All components are fully responsive: +- **Desktop**: Grid layouts, side-by-side buttons +- **Tablet**: Adjusted spacing, smaller grids +- **Mobile**: + - Single column layouts + - Full-width buttons + - Stacked form elements + - Optimized spacing + +## Permissions Summary + +| Feature | Viewer | Member | Admin | +|---------|--------|--------|-------| +| View household info | ✅ | ✅ | ✅ | +| Edit household name | ❌ | ❌ | ✅ | +| View invite code | ❌ | ❌ | ✅ | +| Refresh invite code | ❌ | ❌ | ✅ | +| View members | ✅ | ✅ | ✅ | +| Change member roles | ❌ | ❌ | ✅ | +| Remove members | ❌ | ❌ | ✅ | +| Delete household | ❌ | ❌ | ✅ | +| View stores | ✅ | ✅ | ✅ | +| Add stores | ❌ | ❌ | ✅ | +| Remove stores | ❌ | ❌ | ✅ | +| Set default store | ❌ | ❌ | ✅ | +| Create household | ✅ | ✅ | ✅ | +| Join household | ✅ | ✅ | ✅ | + +## Next Steps + +Consider adding: +1. **Household Settings**: Description, profile image, preferences +2. **Member Invitations**: Direct user search instead of just invite codes +3. **Store Details**: View item counts, last activity per store +4. **Audit Log**: Track household/store changes +5. **Notifications**: Member added/removed, role changes +6. **Bulk Operations**: Remove multiple members at once +7. **Store Categories**: Group stores by region/type +8. **Export Data**: Download household grocery history + +## Testing Checklist + +- [ ] Create new household and verify admin role +- [ ] Generate and copy invite code +- [ ] Join household using invite code +- [ ] Edit household name as admin +- [ ] Promote member to admin +- [ ] Demote admin to member +- [ ] Remove member from household +- [ ] Add store to household +- [ ] Set default store +- [ ] Remove store (verify last store protection) +- [ ] Try admin actions as non-admin (should be hidden/disabled) +- [ ] Delete household and verify redirect +- [ ] Test responsive layouts on mobile/tablet/desktop +- [ ] Verify all error messages display properly +- [ ] Test with multiple households diff --git a/docs/archive/IMPLEMENTATION_STATUS.md b/docs/archive/IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..ef9cb78 --- /dev/null +++ b/docs/archive/IMPLEMENTATION_STATUS.md @@ -0,0 +1,203 @@ +# Multi-Household Implementation - Quick Reference + +## Implementation Status + +### ✅ Sprint 1: Database Foundation (COMPLETE) +- [x] Created migration script: `multi_household_architecture.sql` +- [x] Created migration guide: `MIGRATION_GUIDE.md` +- [x] Created migration runner scripts: `run-migration.sh` / `run-migration.bat` +- [x] **Tested migration on 'grocery' database (copy of Costco)** +- [x] Migration successful - all data migrated correctly +- [x] Verification passed - 0 data integrity issues + +**Migration Results:** +- ✅ 1 Household created: "Main Household" (invite code: MAIN755114) +- ✅ 7 Users migrated (2 system_admins, 5 standard users) +- ✅ 122 Items extracted to master catalog +- ✅ 122 Household lists created +- ✅ 27 Classifications migrated +- ✅ 273 History records preserved +- ✅ All users assigned to household (admin/user roles) +- ✅ 0 orphaned records or data loss + +**Database:** `grocery` (using Costco as template for safety) + +### ⏳ Sprint 2: Backend API (NEXT - READY TO START) +- [ ] Create household.model.js +- [ ] Create store.model.js +- [ ] Update list.model.js for household+store scope +- [ ] Create householdAccess middleware +- [ ] Create storeAccess middleware +- [ ] Create households.controller.js +- [ ] Create stores.controller.js +- [ ] Update lists.controller.js +- [ ] Update users.controller.js +- [ ] Create/update routes for new structure + +### ⏳ Sprint 3: Frontend Core (PENDING) +- [ ] Refactor contexts +- [ ] Create household UI +- [ ] Create store UI + +## New Database Schema + +### Core Tables +1. **households** - Household entities with invite codes +2. **stores** - Store types (Costco, Target, etc.) +3. **household_members** - User membership with per-household roles +4. **household_stores** - Which stores each household uses +5. **items** - Master item catalog (shared) +6. **household_lists** - Lists scoped to household + store +7. **household_item_classifications** - Classifications per household + store +8. **household_list_history** - History tracking + +### Key Relationships +- User → household_members → Household (many-to-many) +- Household → household_stores → Store (many-to-many) +- Household + Store → household_lists → Item (unique per combo) +- household_lists → household_list_history (one-to-many) + +## Role System + +### System-Wide (users.role) +- **system_admin**: App infrastructure control +- **user**: Standard user + +### Household-Scoped (household_members.role) +- **admin**: Full household control +- **user**: Standard member + +## Migration Steps + +1. **Backup**: `pg_dump grocery_list > backup.sql` +2. **Run**: `psql -d grocery_list -f backend/migrations/multi_household_architecture.sql` +3. **Verify**: Check counts, run integrity queries +4. **Test**: Ensure app functionality +5. **Cleanup**: Drop old tables after verification + +## API Changes (Planned) + +### Old Format +``` +GET /api/list +POST /api/list/add +``` + +### New Format +``` +GET /api/households/:hId/stores/:sId/list +POST /api/households/:hId/stores/:sId/list/add +``` + +## Frontend Changes (Planned) + +### New Contexts +```jsx +const { user, isSystemAdmin } = useAuth(); +const { activeHousehold, isAdmin } = useHousehold(); +const { activeStore, householdStores } = useStore(); +``` + +### New Routes +``` +/households - List households +/households/:id/stores/:sId - Grocery list +/households/:id/members - Manage members +/join/:inviteCode - Join household +``` + +## Development Workflow + +### Phase 1: Database (Current) +1. Review migration script +2. Test on local dev database +3. Run verification queries +4. Document any issues + +### Phase 2: Backend API (Next) +1. Create household.model.js +2. Create store.model.js +3. Update list.model.js for household scope +4. Create middleware for household access +5. Update routes + +### Phase 3: Frontend +1. Refactor AuthContext → useAuth() +2. Create HouseholdContext → useHousehold() +3. Create StoreContext → useStore() +4. Build household switcher +5. Build store tabs + +## Testing Checklist + +### Database Migration +- [ ] All tables created +- [ ] All indexes created +- [ ] Users migrated to household +- [ ] Items deduplicated correctly +- [ ] Lists migrated with correct references +- [ ] Classifications preserved +- [ ] History preserved +- [ ] No NULL foreign keys + +### Backend API +- [ ] Household CRUD works +- [ ] Member management works +- [ ] Invite codes work +- [ ] Store management works +- [ ] List operations scoped correctly +- [ ] Permissions enforced +- [ ] History tracked correctly + +### Frontend UI +- [ ] Login/logout works +- [ ] Household switcher works +- [ ] Store tabs work +- [ ] Can create household +- [ ] Can join household +- [ ] Can add items +- [ ] Can mark bought +- [ ] Roles respected in UI + +## Rollback Strategy + +If migration fails: +```sql +ROLLBACK; +``` + +If issues found after: +```bash +psql -d grocery_list < backup.sql +``` + +## Support Resources + +- **Migration Script**: `backend/migrations/multi_household_architecture.sql` +- **Guide**: `backend/migrations/MIGRATION_GUIDE.md` +- **Architecture**: `docs/multi-household-architecture-plan.md` +- **Status**: This file + +## Key Decisions + +1. ✅ Keep users.role for system admin +2. ✅ Simplify household roles to admin/user +3. ✅ Preserve user names in history (no anonymization) +4. ✅ Shared item catalog with household-specific lists +5. ✅ Context pattern refactoring (internal context + custom hooks) + +## Timeline + +- **Week 1-2**: Database migration + testing +- **Week 3-4**: Backend API implementation +- **Week 5-6**: Frontend core implementation +- **Week 7**: Member management +- **Week 8-9**: Testing & polish +- **Week 10**: Production migration + +## Contact + +For questions or issues during implementation, refer to: +- Architecture plan for design decisions +- Migration guide for database steps +- This file for quick status updates diff --git a/docs/archive/TEST_SUITE_README.md b/docs/archive/TEST_SUITE_README.md new file mode 100644 index 0000000..ab6627a --- /dev/null +++ b/docs/archive/TEST_SUITE_README.md @@ -0,0 +1,43 @@ +# API Test Suite + +The test suite has been reorganized into separate files for better maintainability: + +## New Modular Structure (✅ Complete) +- **api-tests.html** - Main HTML file +- **test-config.js** - Global state management +- **test-definitions.js** - All 62 test cases across 8 categories +- **test-runner.js** - Test execution logic +- **test-ui.js** - UI manipulation functions +- **test-styles.css** - All CSS styles + +## How to Use +1. Start the dev server: `docker-compose -f docker-compose.dev.yml up` +2. Navigate to: `http://localhost:5000/test/api-tests.html` +3. Configure credentials (default: admin/admin123) +4. Click "▶ Run All Tests" + +## Features +- ✅ 62 comprehensive tests +- ✅ Collapsible test cards (collapsed by default) +- ✅ Expected field validation with visual indicators +- ✅ Color-coded HTTP status badges +- ✅ Auto-expansion on test run +- ✅ Expand/Collapse all buttons +- ✅ Real-time pass/fail/error states +- ✅ Summary dashboard + +## File Structure +``` +backend/public/ +├── api-tests.html # Main entry point (use this) +├── test-config.js # State management (19 lines) +├── test-definitions.js # Test cases (450+ lines) +├── test-runner.js # Test execution (160+ lines) +├── test-ui.js # UI functions (90+ lines) +└── test-styles.css # All styles (310+ lines) +``` + +## Old File +- **api-test.html** - Original monolithic version (kept for reference) + +Total: ~1030 lines split into 6 clean, modular files diff --git a/docs/code-cleanup-guide.md b/docs/archive/code-cleanup-guide.md similarity index 100% rename from docs/code-cleanup-guide.md rename to docs/archive/code-cleanup-guide.md diff --git a/docs/settings-dark-mode.md b/docs/archive/settings-dark-mode.md similarity index 100% rename from docs/settings-dark-mode.md rename to docs/archive/settings-dark-mode.md diff --git a/docs/classification-implementation.md b/docs/features/classification-implementation.md similarity index 100% rename from docs/classification-implementation.md rename to docs/features/classification-implementation.md diff --git a/docs/image-storage-implementation.md b/docs/features/image-storage-implementation.md similarity index 100% rename from docs/image-storage-implementation.md rename to docs/features/image-storage-implementation.md diff --git a/docs/guides/MOBILE_RESPONSIVE_AUDIT.md b/docs/guides/MOBILE_RESPONSIVE_AUDIT.md new file mode 100644 index 0000000..7fafcc2 --- /dev/null +++ b/docs/guides/MOBILE_RESPONSIVE_AUDIT.md @@ -0,0 +1,283 @@ +# Mobile Responsive Design Audit & Recommendations + +## ✅ Already Mobile-Friendly + +### Components +1. **Navbar** - Just updated with hamburger menu, dropdowns, sticky positioning +2. **AdminPanel** - Has responsive breakpoints (768px, 480px) +3. **Manage page** - Has responsive breakpoints (768px, 480px) +4. **ManageHousehold** - Has 768px breakpoint +5. **Settings** - Has 768px breakpoint +6. **StoreManagement** - Has 768px breakpoint +7. **GroceryList** - Has 480px breakpoint + +## ✅ Recently Completed (2026-01-26) + +### **All Modals** - Mobile optimization COMPLETE ✓ +**Files updated with responsive styles:** +- ✅ `frontend/src/styles/AddImageModal.css` - Added 768px & 480px breakpoints +- ✅ `frontend/src/styles/ImageUploadModal.css` - Added 768px & 480px breakpoints +- ✅ `frontend/src/styles/ItemClassificationModal.css` - Added 768px & 480px breakpoints +- ✅ `frontend/src/styles/SimilarItemModal.css` - Added 768px & 480px breakpoints +- ✅ `frontend/src/styles/components/EditItemModal.css` - Added 768px & 480px breakpoints +- ✅ `frontend/src/styles/components/ConfirmAddExistingModal.css` - Added 768px & 480px breakpoints +- ✅ `frontend/src/styles/ImageModal.css` - Enhanced with 480px breakpoint +- ✅ `frontend/src/styles/components/AddItemWithDetailsModal.css` - Enhanced with 768px breakpoint +- ✅ `frontend/src/styles/ConfirmBuyModal.css` - Already excellent (480px & 360px breakpoints) + +**Mobile improvements implemented:** +- Modal width: 95% at 768px, 100% at 480px +- All buttons: Full-width stacking on mobile with 44px minimum height +- Input fields: 16px font-size to prevent iOS zoom +- Image previews: Responsive sizing (180-200px on mobile) +- Touch targets: 44x44px minimum for all interactive elements +- Overflow: Auto scrolling for tall modals (max-height: 90vh) +- Spacing: Reduced padding on small screens + +## ⚠️ Needs Improvement + +### High Priority + +#### 1. **HouseholdSwitcher** - Dropdown might overflow on mobile +**File:** `frontend/src/styles/components/HouseholdSwitcher.css` + +**Current:** No mobile breakpoints +**Needs:** +```css +@media (max-width: 480px) { + .household-switcher-dropdown { + max-width: 90vw; + right: auto; + left: 50%; + transform: translateX(-50%); + } +} +``` + +#### 2. **StoreTabs** - Horizontal scrolling tabs on mobile +**File:** `frontend/src/styles/components/StoreTabs.css` + +**Needs:** +```css +@media (max-width: 768px) { + .store-tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .store-tab { + min-width: 100px; + font-size: 0.9rem; + padding: 0.6rem 1rem; + } +} +``` + +#### 3. **Login/Register Pages** - Need better mobile padding +**Files:** +- `frontend/src/styles/pages/Login.css` +- `frontend/src/styles/pages/Register.css` + +**Needs:** +```css +@media (max-width: 480px) { + .card { + padding: 1.5rem 1rem; + margin: 0.5rem; + } + + .form-input { + font-size: 16px; /* Prevents iOS zoom on focus */ + } +} +``` + +### Medium Priority + +#### 4. **GroceryList Item Cards** - Could be more touch-friendly +**File:** `frontend/src/styles/pages/GroceryList.css` + +**Current:** Has 480px breakpoint +**Enhancement needed:** +- Increase touch target sizes for mobile +- Better spacing between items on small screens +- Optimize image display on mobile + +#### 5. **AddItemForm** - Input width and spacing +**File:** `frontend/src/styles/components/AddItemForm.css` + +**Has 480px breakpoint** but verify: +- Input font-size is 16px+ (prevents iOS zoom) +- Buttons are full-width on mobile +- Adequate spacing between form elements + +#### 6. **CreateJoinHousehold Modal** +**File:** `frontend/src/styles/components/manage/CreateJoinHousehold.css` + +**Has 600px breakpoint** - Review for: +- Full-screen on very small devices +- Button sizing and spacing +- Tab navigation usability + +### Low Priority + +#### 7. **SuggestionList** - Touch interactions +**File:** `frontend/src/styles/components/SuggestionList.css` + +**Needs:** Mobile-specific styles for: +- Larger tap targets +- Better scrolling behavior +- Touch feedback + +#### 8. **ClassificationSection** - Zone selection on mobile +**File:** `frontend/src/styles/components/ClassificationSection.css` + +**Needs:** +- Ensure zone buttons are touch-friendly +- Stack vertically if needed on small screens + +#### 9. **ImageUploadSection** +**File:** `frontend/src/styles/components/ImageUploadSection.css` + +**Needs:** +- Camera access optimization for mobile +- Preview image sizing +- Upload button sizing + +## 🎯 General Recommendations + +### 1. **Global Styles** +Update `frontend/src/index.css`: +```css +/* Prevent zoom on input focus (iOS) */ +input, select, textarea { + font-size: 16px; +} + +/* Better touch scrolling */ +* { + -webkit-overflow-scrolling: touch; +} + +/* Ensure body doesn't overflow horizontally */ +body { + overflow-x: hidden; +} +``` + +### 2. **Container Max-Widths** +Standardize across the app: +- Small components: `max-width: 600px` +- Medium pages: `max-width: 800px` +- Wide layouts: `max-width: 1200px` +- Always pair with `margin: 0 auto` and `padding: 1rem` + +### 3. **Button Sizing** +Mobile-friendly buttons: +```css +.btn-primary, .btn-secondary { + min-height: 44px; /* Apple's recommended minimum */ + padding: 0.75rem 1.5rem; +} + +@media (max-width: 768px) { + .btn-primary, .btn-secondary { + width: 100%; + margin-bottom: 0.5rem; + } +} +``` + +### 4. **Form Layouts** +Stack form fields on mobile: +```css +.form-row { + display: flex; + gap: 1rem; +} + +@media (max-width: 768px) { + .form-row { + flex-direction: column; + } +} +``` + +### 5. **Image Handling** +Responsive images: +```css +img { + max-width: 100%; + height: auto; +} +``` + +### 6. **Typography** +Adjust for mobile readability: +```css +@media (max-width: 768px) { + h1 { font-size: 1.75rem; } + h2 { font-size: 1.5rem; } + h3 { font-size: 1.25rem; } + body { font-size: 16px; } /* Prevents iOS zoom */ +} +``` + +## 📱 Testing Checklist + +Test on these viewports: +- [ ] 320px (iPhone SE) +- [ ] 375px (iPhone 12/13 Pro) +- [ ] 390px (iPhone 14 Pro) +- [ ] 414px (iPhone Pro Max) +- [ ] 768px (iPad Portrait) +- [ ] 1024px (iPad Landscape) +- [ ] 1280px+ (Desktop) + +Test these interactions: +- [ ] Navigation menu (hamburger) +- [ ] Dropdowns (household, user menu) +- [ ] All modals +- [ ] Form inputs (no zoom on focus) +- [ ] Touch gestures (swipe, long-press) +- [ ] Scrolling (no horizontal overflow) +- [ ] Image upload/viewing +- [ ] Tab navigation + +## 🔄 Future Considerations + +1. **Progressive Web App (PWA)** + - Add manifest.json + - Service worker for offline support + - Install prompt + +2. **Touch Gestures** + - Swipe to delete items + - Pull to refresh lists + - Long-press for context menu + +3. **Keyboard Handling** + - iOS keyboard overlap handling + - Android keyboard behavior + - Input focus management + +4. **Performance** + - Lazy load images + - Virtual scrolling for long lists + - Code splitting by route + +## 📝 How to Maintain Mobile-First Design + +I've updated `.github/copilot-instructions.md` with mobile-first design principles. This will be included in all future conversations automatically. + +**To ensure I remember in new conversations:** +1. ✅ Mobile-first guidelines are now in copilot-instructions.md (automatically loaded) +2. Start conversations with: "Remember to keep mobile/desktop responsiveness in mind" +3. Review this audit document before making UI changes +4. Run mobile testing after any CSS/layout changes + +**Quick reminder phrases:** +- "Make this mobile-friendly" +- "Add responsive breakpoints" +- "Test on mobile viewports" +- "Ensure touch-friendly targets" diff --git a/docs/api-documentation.md b/docs/guides/api-documentation.md similarity index 100% rename from docs/api-documentation.md rename to docs/guides/api-documentation.md diff --git a/docs/frontend-readme.md b/docs/guides/frontend-readme.md similarity index 100% rename from docs/frontend-readme.md rename to docs/guides/frontend-readme.md diff --git a/docs/setup-checklist.md b/docs/guides/setup-checklist.md similarity index 100% rename from docs/setup-checklist.md rename to docs/guides/setup-checklist.md diff --git a/docs/migration/MIGRATION_GUIDE.md b/docs/migration/MIGRATION_GUIDE.md new file mode 100644 index 0000000..65604ca --- /dev/null +++ b/docs/migration/MIGRATION_GUIDE.md @@ -0,0 +1,243 @@ +# Multi-Household Architecture Migration Guide + +## Pre-Migration Checklist + +- [ ] **Backup Database** + ```bash + pg_dump -U your_user -d grocery_list > backup_$(date +%Y%m%d_%H%M%S).sql + ``` + +- [ ] **Test on Staging First** + - Copy production database to staging environment + - Run migration on staging + - Verify all data migrated correctly + - Test application functionality + +- [ ] **Review Migration Script** + - Read through `multi_household_architecture.sql` + - Understand each step + - Note verification queries + +- [ ] **Announce Maintenance Window** + - Notify users of downtime + - Schedule during low-usage period + - Estimate 15-30 minutes for migration + +## Running the Migration + +### 1. Connect to Database + +```bash +psql -U your_user -d grocery_list +``` + +### 2. Run Migration + +```sql +\i backend/migrations/multi_household_architecture.sql +``` + +The script will: +1. ✅ Create 8 new tables +2. ✅ Create default "Main Household" +3. ✅ Create default "Costco" store +4. ✅ Migrate all users to household members +5. ✅ Extract items to master catalog +6. ✅ Migrate grocery_list → household_lists +7. ✅ Migrate classifications +8. ✅ Migrate history records +9. ✅ Update user system roles + +### 3. Verify Migration + +Run these queries inside psql: + +```sql +-- Check household created +SELECT * FROM households; + +-- Check all users migrated +SELECT u.username, u.role as system_role, hm.role as household_role +FROM users u +JOIN household_members hm ON u.id = hm.user_id +ORDER BY u.id; + +-- Check item counts match +SELECT + (SELECT COUNT(DISTINCT item_name) FROM grocery_list) as old_unique_items, + (SELECT COUNT(*) FROM items) as new_items; + +-- Check list counts +SELECT + (SELECT COUNT(*) FROM grocery_list) as old_lists, + (SELECT COUNT(*) FROM household_lists) as new_lists; + +-- Check classification counts +SELECT + (SELECT COUNT(*) FROM item_classification) as old_classifications, + (SELECT COUNT(*) FROM household_item_classifications) as new_classifications; + +-- Check history counts +SELECT + (SELECT COUNT(*) FROM grocery_history) as old_history, + (SELECT COUNT(*) FROM household_list_history) as new_history; + +-- Verify no data loss - check if all old items have corresponding new records +SELECT gl.item_name +FROM grocery_list gl +LEFT JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name)) +LEFT JOIN household_lists hl ON hl.item_id = i.id +WHERE hl.id IS NULL; +-- Should return 0 rows + +-- Check invite code +SELECT name, invite_code FROM households; +``` + +### 4. Test Application + +- [ ] Users can log in +- [ ] Can view "Main Household" list +- [ ] Can add items +- [ ] Can mark items as bought +- [ ] History shows correctly +- [ ] Classifications preserved +- [ ] Images display correctly + +## Post-Migration Cleanup + +**Only after verifying everything works correctly:** + +```sql +-- Drop old tables (CAREFUL - THIS IS IRREVERSIBLE) +DROP TABLE IF EXISTS grocery_history CASCADE; +DROP TABLE IF EXISTS item_classification CASCADE; +DROP TABLE IF EXISTS grocery_list CASCADE; +``` + +## Rollback Plan + +### If Migration Fails + +```sql +-- Inside psql during migration +ROLLBACK; + +-- Then restore from backup +\q +psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql +``` + +### If Issues Found After Migration + +```bash +# Drop the database and restore +dropdb grocery_list +createdb grocery_list +psql -U your_user -d grocery_list < backup_YYYYMMDD_HHMMSS.sql +``` + +## Common Issues & Solutions + +### Issue: Duplicate items in items table +**Cause**: Case-insensitive matching not working +**Solution**: Check item names for leading/trailing spaces + +### Issue: Foreign key constraint errors +**Cause**: User or item references not found +**Solution**: Verify all users and items exist before migrating lists + +### Issue: History not showing +**Cause**: household_list_id references incorrect +**Solution**: Check JOIN conditions in history migration + +### Issue: Images not displaying +**Cause**: BYTEA encoding issues +**Solution**: Verify image_mime_type correctly migrated + +## Migration Timeline + +- **T-0**: Begin maintenance window +- **T+2min**: Backup complete +- **T+3min**: Start migration script +- **T+8min**: Migration complete (for ~1000 items) +- **T+10min**: Run verification queries +- **T+15min**: Test application functionality +- **T+20min**: If successful, announce completion +- **T+30min**: End maintenance window + +## Data Integrity Checks + +```sql +-- Ensure all users belong to at least one household +SELECT u.id, u.username +FROM users u +LEFT JOIN household_members hm ON u.id = hm.user_id +WHERE hm.id IS NULL; +-- Should return 0 rows + +-- Ensure all household lists have valid items +SELECT hl.id +FROM household_lists hl +LEFT JOIN items i ON hl.item_id = i.id +WHERE i.id IS NULL; +-- Should return 0 rows + +-- Ensure all history has valid list references +SELECT hlh.id +FROM household_list_history hlh +LEFT JOIN household_lists hl ON hlh.household_list_id = hl.id +WHERE hl.id IS NULL; +-- Should return 0 rows + +-- Check for orphaned classifications +SELECT hic.id +FROM household_item_classifications hic +LEFT JOIN household_lists hl ON hic.item_id = hl.item_id + AND hic.household_id = hl.household_id + AND hic.store_id = hl.store_id +WHERE hl.id IS NULL; +-- Should return 0 rows (or classifications for removed items, which is ok) +``` + +## Success Criteria + +✅ All tables created successfully +✅ All users migrated to "Main Household" +✅ Item count matches (unique items from old → new) +✅ List count matches (all grocery_list items → household_lists) +✅ Classification count matches +✅ History count matches +✅ No NULL foreign keys +✅ Application loads without errors +✅ Users can perform all CRUD operations +✅ Images display correctly +✅ Bought items still marked as bought +✅ Recently bought still shows correctly + +## Next Steps After Migration + +1. ✅ Update backend models (Sprint 2) +2. ✅ Update API routes +3. ✅ Update controllers +4. ✅ Test all endpoints +5. ✅ Update frontend contexts +6. ✅ Update UI components +7. ✅ Enable multi-household features + +## Support & Troubleshooting + +If issues arise: +1. Check PostgreSQL logs: `/var/log/postgresql/` +2. Check application logs +3. Restore from backup if needed +4. Review migration script for errors + +## Monitoring Post-Migration + +For the first 24 hours after migration: +- Monitor error logs +- Watch for performance issues +- Verify user activity normal +- Check for any data inconsistencies +- Be ready to rollback if critical issues found diff --git a/docs/migration/POST_MIGRATION_UPDATES.md b/docs/migration/POST_MIGRATION_UPDATES.md new file mode 100644 index 0000000..2dc249d --- /dev/null +++ b/docs/migration/POST_MIGRATION_UPDATES.md @@ -0,0 +1,83 @@ +# Post-Migration Updates Required + +This document outlines the remaining updates needed after migrating to the multi-household architecture. + +## ✅ Completed Fixes + +1. **Column name corrections** in `list.model.v2.js`: + - Fixed `item_image` → `custom_image` + - Fixed `image_mime_type` → `custom_image_mime_type` + - Fixed `hlh.list_id` → `hlh.household_list_id` + +2. **SQL query fixes**: + - Fixed ORDER BY with DISTINCT in `getSuggestions` + - Fixed `setBought` to use boolean instead of quantity logic + +3. **Created migration**: `add_notes_column.sql` for missing notes column + +## 🔧 Required Database Migration + +**Run this SQL on your PostgreSQL database:** + +```sql +-- From backend/migrations/add_notes_column.sql +ALTER TABLE household_lists +ADD COLUMN IF NOT EXISTS notes TEXT; + +COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item'; +``` + +## 🧹 Optional Cleanup (Not Critical) + +### Legacy Files Still Present + +These files reference the old `grocery_list` table but are not actively used by the frontend: + +- `backend/models/list.model.js` - Old model +- `backend/controllers/lists.controller.js` - Old controller +- `backend/routes/list.routes.js` - Old routes (still mounted at `/list`) + +**Recommendation**: Can be safely removed once you confirm the new architecture is working, or kept as fallback. + +### Route Cleanup in app.js + +The old `/list` route is still mounted in `backend/app.js`: + +```javascript +const listRoutes = require("./routes/list.routes"); +app.use("/list", listRoutes); // ← Not used by frontend anymore +``` + +**Recommendation**: Comment out or remove once migration is confirmed successful. + +## ✅ No Frontend Changes Needed + +The frontend is already correctly calling the new household-scoped endpoints: +- All calls use `/households/:householdId/stores/:storeId/list/*` pattern +- No references to old `/list/*` endpoints + +## 🎯 Next Steps + +1. **Run the notes column migration** (required for notes feature to work) +2. **Test the application** thoroughly: + - Add items with images + - Mark items as bought/unbought + - Update item quantities and notes + - Test suggestions/autocomplete + - Test recently bought items +3. **Remove legacy files** (optional, once confirmed working) + +## 📝 Architecture Notes + +**Current Structure:** +- All list operations are scoped to `household_id + store_id` +- History tracking uses `household_list_history` table +- Image storage uses `custom_image` and `custom_image_mime_type` columns +- Classifications use `household_item_classifications` table (per household+store) + +**Middleware Chain:** +```javascript +auth → householdAccess → storeAccess → controller +``` + +This ensures users can only access data for households they belong to and stores linked to those households. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..610beb1 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:5000 +VITE_ALLOWED_HOSTS=localhost,127.0.0.1 diff --git a/frontend/index.html b/frontend/index.html index 6e33df9..285654b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,12 +1,15 @@ - - - - Costco Grocery List - - -
- - - + + + + + Grocery App + + + +
+ + + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1efe7c8..8cded8b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", "@types/node": "^24.10.0", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.2", @@ -292,9 +293,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -308,9 +309,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -324,9 +325,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -340,9 +341,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -356,9 +357,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -372,9 +373,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -388,9 +389,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -404,9 +405,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -420,9 +421,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -436,9 +437,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -452,9 +453,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -468,9 +469,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -484,9 +485,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -500,9 +501,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -516,9 +517,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -532,9 +533,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -548,9 +549,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -564,9 +565,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -580,9 +581,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -596,9 +597,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -612,9 +613,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -628,9 +629,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -644,9 +645,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -660,9 +661,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -676,9 +677,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -692,9 +693,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -981,6 +982,21 @@ "node": ">= 8" } }, + "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, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -988,9 +1004,9 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -1001,9 +1017,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -1014,9 +1030,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -1027,9 +1043,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -1040,9 +1056,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -1053,9 +1069,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -1066,9 +1082,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -1079,9 +1095,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -1092,9 +1108,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -1105,9 +1121,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -1118,9 +1134,22 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -1131,9 +1160,22 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -1144,9 +1186,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -1157,9 +1199,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -1170,9 +1212,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -1183,9 +1225,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -1196,9 +1238,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -1208,10 +1250,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -1222,9 +1277,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -1235,9 +1290,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -1248,9 +1303,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -1261,9 +1316,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -1535,21 +1590,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree/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.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "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.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1651,10 +1706,21 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "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==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "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, "dependencies": { "fast-deep-equal": "^3.1.1", @@ -1694,13 +1760,14 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -1719,9 +1786,9 @@ } }, "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.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", @@ -1872,11 +1939,15 @@ "dev": true }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -1903,7 +1974,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -1991,9 +2061,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "bin": { @@ -2003,32 +2073,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -2335,15 +2405,15 @@ } }, "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.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "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==", "funding": [ { "type": "individual", @@ -2541,6 +2611,18 @@ "hermes-estree": "0.25.1" } }, + "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==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2773,9 +2855,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, "dependencies": { "brace-expansion": "^1.1.7" @@ -2787,13 +2869,12 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "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.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -2904,9 +2985,9 @@ "dev": true }, "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, "engines": { "node": ">=8.6" @@ -2915,10 +2996,54 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "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.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2935,7 +3060,7 @@ } ], "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2953,9 +3078,12 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "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==", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -3015,9 +3143,9 @@ } }, "node_modules/react-router": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", - "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -3036,11 +3164,11 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", - "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", "dependencies": { - "react-router": "7.9.6" + "react-router": "7.15.1" }, "engines": { "node": ">=20.0.0" @@ -3070,9 +3198,9 @@ } }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "dependencies": { "@types/estree": "1.0.8" @@ -3085,28 +3213,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, @@ -3240,9 +3371,9 @@ } }, "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "engines": { "node": ">=12" @@ -3369,12 +3500,12 @@ } }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -3460,9 +3591,9 @@ } }, "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "engines": { "node": ">=12" diff --git a/frontend/package.json b/frontend/package.json index d04b226..6e454a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,9 +5,13 @@ "type": "module", "scripts": { "dev": "vite", + "typecheck": "tsc -b", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "node scripts/run-playwright.mjs", + "test:e2e:headed": "node scripts/run-playwright.mjs --headed", + "test:e2e:ui": "node scripts/run-playwright.mjs --ui" }, "dependencies": { "axios": "^1.13.2", @@ -17,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", "@types/node": "^24.10.0", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..0cdb0ec --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "@playwright/test"; + +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3010"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chrome", + use: { browserName: "chromium", channel: "chrome" }, + }, + ], +}); diff --git a/frontend/scripts/run-playwright.mjs b/frontend/scripts/run-playwright.mjs new file mode 100644 index 0000000..baf9f0b --- /dev/null +++ b/frontend/scripts/run-playwright.mjs @@ -0,0 +1,80 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; + +const baseUrl = new URL(process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3010"); +const port = Number(baseUrl.port || 3010); +const host = baseUrl.hostname || "localhost"; + +let child = null; +let closing = false; + +const server = await createServer({ + server: { + host, + port, + strictPort: true, + }, +}); + +async function closeServer() { + if (closing) { + return; + } + closing = true; + await server.close(); +} + +async function shutdown(signal) { + if (child && !child.killed) { + child.kill(signal); + } + + try { + await closeServer(); + } finally { + process.exit(signal === "SIGINT" || signal === "SIGTERM" ? 130 : 1); + } +} + +process.once("SIGINT", () => { + void shutdown("SIGINT"); +}); + +process.once("SIGTERM", () => { + void shutdown("SIGTERM"); +}); + +await server.listen(); +server.printUrls(); + +const playwrightCli = fileURLToPath( + new URL("../node_modules/@playwright/test/cli.js", import.meta.url) +); +const frontendRoot = fileURLToPath(new URL("..", import.meta.url)); + +child = spawn(process.execPath, [playwrightCli, "test", ...process.argv.slice(2)], { + cwd: frontendRoot, + env: { + ...process.env, + PLAYWRIGHT_BASE_URL: baseUrl.toString().replace(/\/$/, ""), + PLAYWRIGHT_SKIP_WEBSERVER: "1", + }, + stdio: "inherit", +}); + +child.on("exit", async (code, signal) => { + try { + await closeServer(); + } catch (error) { + console.error("Failed to close Vite after Playwright run:", error); + process.exit(1); + } + + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f9595cd..012284b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,60 +1,74 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { ROLES } from "./constants/roles"; -import { AuthProvider } from "./context/AuthContext.jsx"; -import { ConfigProvider } from "./context/ConfigContext.jsx"; -import { SettingsProvider } from "./context/SettingsContext.jsx"; +import { ROLES } from "./constants/roles"; +import { AuthProvider } from "./context/AuthContext.jsx"; +import { ActionToastProvider } from "./context/ActionToastContext.jsx"; +import { ConfigProvider } from "./context/ConfigContext.jsx"; +import { HouseholdProvider } from "./context/HouseholdContext.jsx"; +import { UploadQueueProvider } from "./context/UploadQueueContext.jsx"; +import { SettingsProvider } from "./context/SettingsContext.jsx"; +import { StoreProvider } from "./context/StoreContext.jsx"; import AdminPanel from "./pages/AdminPanel.jsx"; import GroceryList from "./pages/GroceryList.jsx"; -import Login from "./pages/Login.jsx"; -import Register from "./pages/Register.jsx"; -import Settings from "./pages/Settings.jsx"; - -import AppLayout from "./components/layout/AppLayout.jsx"; -import PrivateRoute from "./utils/PrivateRoute.jsx"; - -import RoleGuard from "./utils/RoleGuard.jsx"; - - -function App() { - return ( - - - - - - - {/* Public route */} - } /> - } /> - - {/* Private routes with layout */} - - -
- } - > - } /> - } /> - - - -
- } - /> - - - - - - - - ); -} - -export default App; \ No newline at end of file +import Login from "./pages/Login.jsx"; +import Manage from "./pages/Manage.jsx"; +import Register from "./pages/Register.jsx"; +import Settings from "./pages/Settings.jsx"; +import InviteLink from "./pages/InviteLink.jsx"; + +import AppLayout from "./components/layout/AppLayout.jsx"; +import UploadToaster from "./components/common/UploadToaster.jsx"; +import PrivateRoute from "./utils/PrivateRoute.jsx"; +import RoleGuard from "./utils/RoleGuard.jsx"; + +function App() { + return ( + + + + + + + + + + {/* Public route */} + } /> + } /> + } /> + + {/* Private routes with layout */} + + +
+ } + > + } /> + } /> + } /> + + + + + } + /> + + + + + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index 2b271c7..180302a 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -5,7 +5,12 @@ export const loginRequest = async (username, password) => { return res.data; }; -export const registerRequest = async (username, password, name) => { - const res = await api.post("/auth/register", { username, password, name }); - return res.data; -}; \ No newline at end of file +export const registerRequest = async (username, password, name) => { + const res = await api.post("/auth/register", { username, password, name }); + return res.data; +}; + +export const logoutRequest = async () => { + const res = await api.post("/auth/logout"); + return res.data; +}; diff --git a/frontend/src/api/availableItems.js b/frontend/src/api/availableItems.js new file mode 100644 index 0000000..bac18d9 --- /dev/null +++ b/frontend/src/api/availableItems.js @@ -0,0 +1,55 @@ +import api from "./axios"; + +function appendClassification(formData, classification) { + if (classification === undefined) { + return; + } + + formData.append("classification", JSON.stringify(classification)); +} + +export const getAvailableItems = (householdId, storeId, query = "") => + api.get(`/households/${householdId}/stores/${storeId}/available-items`, { + params: query ? { query } : undefined, + }); + +export const createAvailableItem = (householdId, storeId, payload) => { + const formData = new FormData(); + formData.append("item_name", payload.itemName); + appendClassification(formData, payload.classification ?? undefined); + if (payload.imageFile) { + formData.append("image", payload.imageFile); + } + + return api.post(`/households/${householdId}/stores/${storeId}/available-items`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); +}; + +export const updateAvailableItem = (householdId, storeId, itemId, payload) => { + const formData = new FormData(); + if (payload.itemName !== undefined) { + formData.append("item_name", payload.itemName); + } + appendClassification(formData, payload.classification); + if (payload.removeImage) { + formData.append("remove_image", "true"); + } + if (payload.imageFile) { + formData.append("image", payload.imageFile); + } + + return api.patch(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); +}; + +export const deleteAvailableItem = (householdId, storeId, itemId) => + api.delete(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`); + +export const importCurrentAvailableItems = (householdId, storeId) => + api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`); diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index bee1254..f10c1c4 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -1,11 +1,12 @@ import axios from "axios"; import { API_BASE_URL } from "../config"; -const api = axios.create({ - baseURL: API_BASE_URL, - headers: { - "Content-Type": "application/json", - }, +const api = axios.create({ + baseURL: API_BASE_URL, + withCredentials: true, + headers: { + "Content-Type": "application/json", + }, }); api.interceptors.request.use((config => { @@ -16,17 +17,46 @@ api.interceptors.request.use((config => { return config; })); -api.interceptors.response.use( - response => response, - error => { - if (error.response?.status === 401 && - error.response?.data?.message === "Invalid or expired token") { - localStorage.removeItem("token"); - window.location.href = "/login"; - alert("Your session has expired. Please log in again."); - } +api.interceptors.response.use( + response => { + const payload = response.data; + if ( + payload && + typeof payload === "object" && + !Array.isArray(payload) && + Object.keys(payload).length === 2 && + Object.prototype.hasOwnProperty.call(payload, "data") && + Object.prototype.hasOwnProperty.call(payload, "request_id") + ) { + response.request_id = payload.request_id; + response.data = payload.data; + } + return response; + }, + error => { + const payload = error.response?.data; + const normalizedMessage = payload?.error?.message || payload?.message; + + if (payload?.error?.message && payload.message === undefined) { + payload.message = payload.error.message; + } + + if ( + error.response?.status === 401 && + window.location.pathname !== "/login" && + window.location.pathname !== "/register" && + [ + "Invalid or expired token", + "Invalid or expired session", + "Missing authentication", + ].includes(normalizedMessage) + ) { + localStorage.removeItem("token"); + window.location.href = "/login"; + alert("Your session has expired. Please log in again."); + } return Promise.reject(error); } ); -export default api; \ No newline at end of file +export default api; diff --git a/frontend/src/api/households.js b/frontend/src/api/households.js new file mode 100644 index 0000000..9ac78e4 --- /dev/null +++ b/frontend/src/api/households.js @@ -0,0 +1,91 @@ +import api from "./axios"; + +/** + * Get all households for the current user + */ +export const getUserHouseholds = () => api.get("/households"); + +/** + * Get details of a specific household + */ +export const getHousehold = (householdId) => api.get(`/households/${householdId}`); + +/** + * Create a new household + */ +export const createHousehold = (name) => api.post("/households", { name }); + +/** + * Update household name + */ +export const updateHousehold = (householdId, name) => + api.patch(`/households/${householdId}`, { name }); + +/** + * Delete a household + */ +export const deleteHousehold = (householdId) => + api.delete(`/households/${householdId}`); + +/** + * Get household members + */ +export const getHouseholdMembers = (householdId) => + api.get(`/households/${householdId}/members`); + +/** + * Update member role + */ +export const updateMemberRole = (householdId, userId, role) => + api.patch(`/households/${householdId}/members/${userId}/role`, { role }); + +/** + * Remove member from household + */ +export const removeMember = (householdId, userId) => + api.delete(`/households/${householdId}/members/${userId}`); + +function groupHeaders(groupId) { + return { + headers: { + "x-group-id": String(groupId), + }, + }; +} + +export const getGroupInviteLinks = (groupId) => + api.get("/api/groups/invites", groupHeaders(groupId)); + +export const getPendingGroupJoinRequests = (groupId) => + api.get("/api/groups/join-requests", groupHeaders(groupId)); + +export const createGroupInviteLink = (groupId, payload) => + api.post("/api/groups/invites", payload, groupHeaders(groupId)); + +export const decideGroupJoinRequest = (groupId, requestId, decision) => + api.post( + "/api/groups/join-requests/decision", + { requestId, decision }, + groupHeaders(groupId) + ); + +export const revokeGroupInviteLink = (groupId, linkId) => + api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId)); + +export const reviveGroupInviteLink = (groupId, linkId, ttlDays) => + api.post("/api/groups/invites/revive", { linkId, ttlDays }, groupHeaders(groupId)); + +export const deleteGroupInviteLink = (groupId, linkId) => + api.post("/api/groups/invites/delete", { linkId }, groupHeaders(groupId)); + +export const getGroupJoinPolicy = (groupId) => + api.get("/api/groups/join-policy", groupHeaders(groupId)); + +export const setGroupJoinPolicy = (groupId, joinPolicy) => + api.post("/api/groups/join-policy", { joinPolicy }, groupHeaders(groupId)); + +export const getInviteLinkSummary = (token) => + api.get(`/api/invite-links/${encodeURIComponent(token)}`); + +export const acceptInviteLink = (token) => + api.post(`/api/invite-links/${encodeURIComponent(token)}`); diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index f202292..259d402 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -1,46 +1,169 @@ import api from "./axios"; -export const getList = () => api.get("/list"); -export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } }); +/** + * Get grocery list for household and store + */ +export const getList = (householdId, storeId) => + api.get(`/households/${householdId}/stores/${storeId}/list`); -export const addItem = (itemName, quantity, imageFile = null) => { - const formData = new FormData(); - formData.append("itemName", itemName); - formData.append("quantity", quantity); +/** + * Get specific item by name + */ +export const getItemByName = (householdId, storeId, itemName) => + api.get(`/households/${householdId}/stores/${storeId}/list/item`, { + params: { item_name: itemName } + }); - if (imageFile) { - formData.append("image", imageFile); - } +/** + * Add item to list + */ +export const addItem = ( + householdId, + storeId, + itemName, + quantity, + imageFile = null, + notes = null, + addedForUserId = null +) => { + const formData = new FormData(); + formData.append("item_name", itemName); + formData.append("quantity", quantity); + if (notes) { + formData.append("notes", notes); + } + if (addedForUserId != null) { + formData.append("added_for_user_id", addedForUserId); + } + if (imageFile) { + formData.append("image", imageFile); + } - return api.post("/list/add", formData, { + return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, { headers: { "Content-Type": "multipart/form-data", }, }); }; -export const getClassification = (id) => api.get(`/list/item/${id}/classification`); -export const updateItemWithClassification = (id, itemName, quantity, classification) => { - return api.put(`/list/item/${id}`, { - itemName, +/** + * Get item classification + */ +export const getClassification = (householdId, storeId, itemName) => + api.get(`/households/${householdId}/stores/${storeId}/list/classification`, { + params: { item_name: itemName } + }); + +/** + * Set item classification + */ +export const setClassification = (householdId, storeId, itemName, classification) => + api.post(`/households/${householdId}/stores/${storeId}/list/classification`, { + item_name: itemName, + classification + }); + +function normalizeClassificationPayload(classification) { + if (!classification) return null; + if (typeof classification === "string") { + return classification.trim() || null; + } + if (typeof classification !== "object" || Array.isArray(classification)) { + return null; + } + + const payload = { + item_type: typeof classification.item_type === "string" && classification.item_type.trim() + ? classification.item_type.trim() + : null, + item_group: typeof classification.item_group === "string" && classification.item_group.trim() + ? classification.item_group.trim() + : null, + zone: typeof classification.zone === "string" && classification.zone.trim() + ? classification.zone.trim() + : null, + }; + + return payload.item_type || payload.item_group || payload.zone ? payload : null; +} + +/** + * Update item with optional classification details. + */ +export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => { + const normalizedClassification = normalizeClassificationPayload(classification); + return Promise.all([ + updateItem(householdId, storeId, itemName, quantity), + normalizedClassification + ? setClassification(householdId, storeId, itemName, normalizedClassification) + : Promise.resolve() + ]); +}; + +/** + * Update item details (quantity, notes) + */ +export const updateItem = (householdId, storeId, itemName, quantity, notes) => + api.put(`/households/${householdId}/stores/${storeId}/list/item`, { + item_name: itemName, quantity, - classification + notes }); -}; -export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity }); -export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); -export const getRecentlyBought = () => api.get("/list/recently-bought"); -export const updateItemImage = (id, itemName, quantity, imageFile) => { - const formData = new FormData(); - formData.append("id", id); - formData.append("itemName", itemName); - formData.append("quantity", quantity); - formData.append("image", imageFile); - - return api.post("/list/update-image", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, +/** + * Mark item as bought or unbought + */ +export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) => + api.patch(`/households/${householdId}/stores/${storeId}/list/item`, { + item_name: itemName, + bought, + quantity_bought: quantityBought }); -}; \ No newline at end of file + +/** + * Delete item from list + */ +export const deleteItem = (householdId, storeId, itemName) => + api.delete(`/households/${householdId}/stores/${storeId}/list/item`, { + data: { item_name: itemName } + }); + +/** + * Get suggestions based on query + */ +export const getSuggestions = (householdId, storeId, query) => + api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, { + params: { query } + }); + +/** + * Get recently bought items + */ +export const getRecentlyBought = (householdId, storeId) => + api.get(`/households/${householdId}/stores/${storeId}/list/recent`); + +/** + * Update item image + */ +export const updateItemImage = ( + householdId, + storeId, + itemName, + quantity, + imageFile, + options = {} +) => { + const formData = new FormData(); + formData.append("item_name", itemName); + formData.append("quantity", quantity); + formData.append("image", imageFile); + + return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + onUploadProgress: options.onUploadProgress, + signal: options.signal, + timeout: options.timeoutMs, + }); +}; diff --git a/frontend/src/api/stores.js b/frontend/src/api/stores.js new file mode 100644 index 0000000..2b92f18 --- /dev/null +++ b/frontend/src/api/stores.js @@ -0,0 +1,48 @@ +import api from "./axios"; + +/** + * Get all stores in the system + */ +export const getAllStores = () => api.get("/stores"); + +/** + * Get stores linked to a household + */ +export const getHouseholdStores = (householdId) => + api.get(`/stores/household/${householdId}`); + +/** + * Add a store to a household + */ +export const addStoreToHousehold = (householdId, storeId, isDefault = false) => + api.post(`/stores/household/${householdId}`, { storeId: storeId, isDefault: isDefault }); + +/** + * Remove a store from a household + */ +export const removeStoreFromHousehold = (householdId, storeId) => + api.delete(`/stores/household/${householdId}/${storeId}`); + +/** + * Set a store as default for a household + */ +export const setDefaultStore = (householdId, storeId) => + api.patch(`/stores/household/${householdId}/${storeId}/default`); + +/** + * Create a new store (system admin only) + */ +export const createStore = (name, location) => + api.post("/stores", { name, location }); + +/** + * Update store details (system admin only) + */ +export const updateStore = (storeId, name, location) => + api.patch(`/stores/${storeId}`, { name, location }); + +/** + * Delete a store (system admin only) + */ +export const deleteStore = (storeId) => + api.delete(`/stores/${storeId}`); diff --git a/frontend/src/components/admin/StoreManagement.jsx b/frontend/src/components/admin/StoreManagement.jsx new file mode 100644 index 0000000..5136c1d --- /dev/null +++ b/frontend/src/components/admin/StoreManagement.jsx @@ -0,0 +1,295 @@ +import { useEffect, useState } from "react"; +import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; +import "../../styles/components/admin/StoreManagement.css"; + +export default function StoreManagement() { + const toast = useActionToast(); + const [stores, setStores] = useState([]); + const [loading, setLoading] = useState(true); + const [editingStore, setEditingStore] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + const [formData, setFormData] = useState({ + name: "", + zones: [] + }); + const [newZone, setNewZone] = useState(""); + + useEffect(() => { + loadStores(); + }, []); + + const loadStores = async () => { + setLoading(true); + try { + const response = await getAllStores(); + setStores(response.data); + } catch (error) { + console.error("Failed to load stores:", error); + const message = getApiErrorMessage(error, "Failed to load stores"); + toast.error("Load stores failed", `Load stores failed: ${message}`); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (e) => { + e.preventDefault(); + if (!formData.name.trim()) return; + + try { + const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null; + await createStore(formData.name, zonesJson); + await loadStores(); + toast.success("Created store", `Created store ${formData.name.trim()}`); + setShowCreateForm(false); + setFormData({ name: "", zones: [] }); + setNewZone(""); + } catch (error) { + console.error("Failed to create store:", error); + const message = getApiErrorMessage(error, "Failed to create store"); + toast.error("Create store failed", `Create store failed: ${message}`); + } + }; + + const handleUpdate = async (e) => { + e.preventDefault(); + if (!editingStore || !formData.name.trim()) return; + + try { + const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null; + await updateStore(editingStore.id, formData.name, zonesJson); + await loadStores(); + toast.success("Updated store", `Updated store ${formData.name.trim()}`); + setEditingStore(null); + setFormData({ name: "", zones: [] }); + setNewZone(""); + } catch (error) { + console.error("Failed to update store:", error); + const message = getApiErrorMessage(error, "Failed to update store"); + toast.error("Update store failed", `Update store failed: ${message}`); + } + }; + + const handleDelete = async (storeId, storeName) => { + if (!confirm(`Delete store "${storeName}"? This cannot be undone.`)) return; + + try { + await deleteStore(storeId); + await loadStores(); + toast.success("Deleted store", `Deleted store ${storeName}`); + } catch (error) { + console.error("Failed to delete store:", error); + const message = getApiErrorMessage(error, "Failed to delete store"); + toast.error("Delete store failed", `Delete store failed: ${message}`); + } + }; + + const startEdit = (store) => { + console.log('Starting edit for store:', store); + setEditingStore(store); + let zones = []; + if (store.default_zones) { + try { + let parsed = typeof store.default_zones === 'string' + ? JSON.parse(store.default_zones) + : store.default_zones; + + // Handle both formats: direct array or object with zones property + if (Array.isArray(parsed)) { + zones = parsed; + } else if (parsed && Array.isArray(parsed.zones)) { + zones = parsed.zones; + } + } catch (e) { + console.error('Failed to parse zones:', e); + zones = []; + } + } + console.log('Parsed zones:', zones); + setFormData({ + name: store.name, + zones: zones + }); + setShowCreateForm(false); + }; + + const cancelEdit = () => { + setEditingStore(null); + setFormData({ name: "", zones: [] }); + setNewZone(""); + }; + + const startCreate = () => { + setShowCreateForm(true); + setEditingStore(null); + setFormData({ name: "", zones: [] }); + setNewZone(""); + }; + + const addZone = () => { + const zone = newZone.trim(); + if (!zone) return; + if (formData.zones.includes(zone)) { + alert("Zone already exists"); + return; + } + setFormData({ ...formData, zones: [...formData.zones, zone] }); + setNewZone(""); + }; + + const removeZone = (index) => { + setFormData({ + ...formData, + zones: formData.zones.filter((_, i) => i !== index) + }); + }; + + const parseZones = (defaultZones) => { + if (!defaultZones) return []; + try { + let parsed = typeof defaultZones === 'string' ? JSON.parse(defaultZones) : defaultZones; + + // Handle both formats: direct array or object with zones property + if (Array.isArray(parsed)) { + return parsed; + } else if (parsed && Array.isArray(parsed.zones)) { + return parsed.zones; + } + return []; + } catch (e) { + return []; + } + }; + + return ( +
+
+

Store Management

+ {!showCreateForm && !editingStore && ( + + )} +
+ + {/* Create/Edit Form */} + {(showCreateForm || editingStore) && ( +
+

{editingStore ? "Edit Store" : "Create New Store"}

+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Costco Richmond" + required + autoFocus + /> +
+ +
+ +
+ setNewZone(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addZone())} + placeholder="Enter zone name and press Enter or click Add" + /> + +
+ {formData.zones.length > 0 && ( +
+ {formData.zones.map((zone, index) => ( +
+ {zone} + +
+ ))} +
+ )} +

+ Add zones that will be used for organizing items in this store +

+
+ +
+ + +
+
+
+ )} + + {/* Stores List */} +
+ {loading ? ( +

Loading stores...

+ ) : stores.length === 0 ? ( +

No stores found. Create one to get started.

+ ) : ( + stores.map((store) => ( +
+
+

{store.name}

+ {store.default_zones && parseZones(store.default_zones).length > 0 && ( +
+

Default Zones:

+
+ {parseZones(store.default_zones).map((zone, idx) => ( + {zone} + ))} +
+
+ )} +

ID: {store.id}

+
+
+ + +
+
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/components/common/ToggleButtonGroup.jsx b/frontend/src/components/common/ToggleButtonGroup.jsx new file mode 100644 index 0000000..761628f --- /dev/null +++ b/frontend/src/components/common/ToggleButtonGroup.jsx @@ -0,0 +1,67 @@ +import "../../styles/components/ToggleButtonGroup.css"; + +function joinClasses(parts) { + return parts.filter(Boolean).join(" "); +} + +export default function ToggleButtonGroup({ + value, + options, + onChange, + ariaLabel, + role = "group", + className = "tbg-group", + buttonBaseClassName = "tbg-button", + buttonClassName, + activeClassName = "is-active", + inactiveClassName = "is-inactive", + sizeClassName = "tbg-size-default" +}) { + const optionCount = Math.max(options.length, 1); + const activeIndex = + value == null ? -1 : options.findIndex((option) => option.value === value); + + const groupStyle = { + "--tbg-option-count": optionCount, + "--tbg-active-index": activeIndex >= 0 ? activeIndex : 0 + }; + + return ( +
= 0 && "has-active"])} + role={role} + aria-label={ariaLabel} + style={groupStyle} + > +
+ ); +} diff --git a/frontend/src/components/common/UploadToaster.jsx b/frontend/src/components/common/UploadToaster.jsx new file mode 100644 index 0000000..5d7fb26 --- /dev/null +++ b/frontend/src/components/common/UploadToaster.jsx @@ -0,0 +1,92 @@ +import useUploadQueue from "../../hooks/useUploadQueue"; +import useActionToast from "../../hooks/useActionToast"; +import "../../styles/components/UploadToaster.css"; + +function getStatusLabel(upload, isOnline) { + if (upload.status === "uploading") { + return `Uploading... ${upload.progress || 0}%`; + } + if (upload.status === "success") { + return "Upload complete"; + } + if (upload.status === "queued") { + return isOnline ? "Queued for upload..." : "Waiting for network..."; + } + return upload.lastError || "Upload failed. Retry or discard."; +} + +export default function UploadToaster() { + const { uploads, isOnline, retryUpload, discardUpload } = useUploadQueue(); + const { toasts, dismiss } = useActionToast(); + + if (!uploads.length && !toasts.length) { + return null; + } + + const sortedToasts = [...toasts].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + const sortedUploads = [...uploads].sort( + (a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0) + ); + + return ( +
+ {sortedToasts.map((toast) => ( +
+
+
{toast.title}
+ +
+ {toast.message ?
{toast.message}
: null} +
+ ))} + + {sortedUploads.map((upload) => ( +
+
{upload.itemName}
+
{getStatusLabel(upload, isOnline)}
+ +
+
+
+ + {upload.status === "failed" && ( +
+ + +
+ )} + + {upload.status === "queued" && ( +
+ +
+ )} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/common/UserRoleCard.jsx b/frontend/src/components/common/UserRoleCard.jsx index 4e91bed..93c2fa0 100644 --- a/frontend/src/components/common/UserRoleCard.jsx +++ b/frontend/src/components/common/UserRoleCard.jsx @@ -1,6 +1,8 @@ import { ROLES } from "../../constants/roles"; export default function UserRoleCard({ user, onRoleChange }) { + console.log(user) + return (
diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index 0dc675e..d553900 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -3,5 +3,6 @@ export { default as ErrorMessage } from './ErrorMessage.jsx'; export { default as FloatingActionButton } from './FloatingActionButton.jsx'; export { default as FormInput } from './FormInput.jsx'; export { default as SortDropdown } from './SortDropdown.jsx'; +export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx'; export { default as UserRoleCard } from './UserRoleCard.jsx'; diff --git a/frontend/src/components/forms/AddItemForm.jsx b/frontend/src/components/forms/AddItemForm.jsx index 6f889eb..3e5b934 100644 --- a/frontend/src/components/forms/AddItemForm.jsx +++ b/frontend/src/components/forms/AddItemForm.jsx @@ -1,19 +1,56 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { ToggleButtonGroup } from "../common"; +import AssignItemForModal from "../modals/AssignItemForModal"; import "../../styles/components/AddItemForm.css"; import SuggestionList from "../items/SuggestionList"; -export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add" }) { +export default function AddItemForm({ + onAdd, + onSuggest, + suggestions, + buttonText = "Add", + householdMembers = [], + currentUserId = null +}) { const [itemName, setItemName] = useState(""); const [quantity, setQuantity] = useState(1); const [showSuggestions, setShowSuggestions] = useState(false); + const [assignmentMode, setAssignmentMode] = useState("me"); + const [assignedUserId, setAssignedUserId] = useState(null); + const [showAssignModal, setShowAssignModal] = useState(false); + + const numericCurrentUserId = + currentUserId == null ? null : Number.parseInt(String(currentUserId), 10); + + const otherMembers = useMemo( + () => householdMembers.filter((member) => Number(member.id) !== numericCurrentUserId), + [householdMembers, numericCurrentUserId] + ); + + const assignedMemberLabel = useMemo(() => { + if (assignmentMode !== "others" || assignedUserId == null) return ""; + const member = otherMembers.find((item) => Number(item.id) === Number(assignedUserId)); + return member ? (member.display_name || member.name || member.username || `User ${member.id}`) : ""; + }, [assignmentMode, assignedUserId, otherMembers]); const handleSubmit = (e) => { e.preventDefault(); if (!itemName.trim()) return; - onAdd(itemName, quantity); + if (assignmentMode === "others" && assignedUserId == null) { + if (otherMembers.length > 0) { + setShowAssignModal(true); + } + return; + } + + const targetUserId = assignmentMode === "others" ? Number(assignedUserId) : null; + onAdd(itemName, quantity, targetUserId); setItemName(""); setQuantity(1); + setAssignmentMode("me"); + setAssignedUserId(null); + setShowAssignModal(false); }; const handleInputChange = (text) => { @@ -35,30 +72,78 @@ export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText setQuantity(prev => Math.max(1, prev - 1)); }; + const handleAssignmentModeChange = (mode) => { + if (mode === "me") { + setAssignmentMode("me"); + setAssignedUserId(null); + setShowAssignModal(false); + return; + } + + if (otherMembers.length === 0) { + setAssignmentMode("me"); + setAssignedUserId(null); + return; + } + + setAssignmentMode("others"); + setShowAssignModal(true); + }; + + const handleAssignCancel = () => { + setShowAssignModal(false); + setAssignmentMode("me"); + setAssignedUserId(null); + }; + + const handleAssignConfirm = (memberId) => { + setShowAssignModal(false); + setAssignmentMode("others"); + setAssignedUserId(Number(memberId)); + }; + const isDisabled = !itemName.trim(); return (
-
- handleInputChange(e.target.value)} - onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} - onClick={() => setShowSuggestions(true)} - /> - - {showSuggestions && suggestions.length > 0 && ( - +
+ handleInputChange(e.target.value)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} + onClick={() => setShowSuggestions(true)} /> - )} + + {showSuggestions && suggestions.length > 0 && ( + + )} +
+ +
+ {assignmentMode === "others" && assignedMemberLabel ? ( +

Adding for: {assignedMemberLabel}

+ ) : null} +
+ +
); } diff --git a/frontend/src/components/household/HouseholdSwitcher.jsx b/frontend/src/components/household/HouseholdSwitcher.jsx new file mode 100644 index 0000000..8a01b27 --- /dev/null +++ b/frontend/src/components/household/HouseholdSwitcher.jsx @@ -0,0 +1,101 @@ +import { useContext, useState } from "react"; +import { HouseholdContext } from "../../context/HouseholdContext"; +import "../../styles/components/HouseholdSwitcher.css"; +import CreateJoinHousehold from "../manage/CreateJoinHousehold"; + +export default function HouseholdSwitcher() { + const { + households, + activeHousehold, + setActiveHousehold, + loading, + hasLoaded, + } = useContext(HouseholdContext); + const [isOpen, setIsOpen] = useState(false); + const [showCreateJoin, setShowCreateJoin] = useState(false); + + if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { + return ( +
+ +
+ ); + } + + if (!activeHousehold || households.length === 0) { + return ( + <> +
+ +
+ + {showCreateJoin && ( + setShowCreateJoin(false)} /> + )} + + ); + } + + const handleSelect = (household) => { + setActiveHousehold(household); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+ {households.map((household) => ( + + ))} +
+ +
+ + )} + + {showCreateJoin && ( + setShowCreateJoin(false)} /> + )} +
+ ); +} diff --git a/frontend/src/components/household/NoHouseholdState.jsx b/frontend/src/components/household/NoHouseholdState.jsx new file mode 100644 index 0000000..1bdb4bb --- /dev/null +++ b/frontend/src/components/household/NoHouseholdState.jsx @@ -0,0 +1,69 @@ +import { useContext, useState } from "react"; +import { HouseholdContext } from "../../context/HouseholdContext"; +import "../../styles/components/NoHouseholdState.css"; +import CreateJoinHousehold from "../manage/CreateJoinHousehold"; + +export default function NoHouseholdState({ + title = "No household yet", + description = "Create a household to start building lists, or join one with an invite link.", +}) { + const { error, refreshHouseholds } = useContext(HouseholdContext); + const [showCreateJoin, setShowCreateJoin] = useState(false); + const [modalMode, setModalMode] = useState("create"); + + const openModal = (nextMode) => { + setModalMode(nextMode); + setShowCreateJoin(true); + }; + + return ( + <> +
+
+

Welcome

+

{title}

+

{description}

+ + {error && ( +

+ We couldn't load households: {error} +

+ )} + +
+ + + {error && ( + + )} +
+
+
+ + {showCreateJoin && ( + setShowCreateJoin(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/items/GroceryListItem.jsx b/frontend/src/components/items/GroceryListItem.jsx index 0d86042..abdc152 100644 --- a/frontend/src/components/items/GroceryListItem.jsx +++ b/frontend/src/components/items/GroceryListItem.jsx @@ -1,11 +1,15 @@ import { memo, useRef, useState } from "react"; import AddImageModal from "../modals/AddImageModal"; -import ConfirmBuyModal from "../modals/ConfirmBuyModal"; -function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) { +function GroceryListItem({ + item, + onClick, + onOpenBuyModal, + onImageAdded, + onLongPress, + compact = false +}) { const [showAddImageModal, setShowAddImageModal] = useState(false); - const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false); - const [currentItem, setCurrentItem] = useState(item); const longPressTimer = useRef(null); const pressStartPos = useRef({ x: 0, y: 0 }); @@ -56,32 +60,14 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = const handleItemClick = () => { if (onClick) { - setCurrentItem(item); - setShowConfirmBuyModal(true); + onClick(item); } }; - const handleConfirmBuy = (quantity) => { - if (onClick) { - onClick(currentItem.id, quantity); - } - setShowConfirmBuyModal(false); - }; - - const handleCancelBuy = () => { - setShowConfirmBuyModal(false); - }; - - const handleNavigate = (newItem) => { - setCurrentItem(newItem); - }; - const handleImageClick = (e) => { e.stopPropagation(); // Prevent triggering the bought action - if (item.item_image) { - // Open buy modal which now shows the image - setCurrentItem(item); - setShowConfirmBuyModal(true); + if (item.item_image && onOpenBuyModal) { + onOpenBuyModal(item); } else { setShowAddImageModal(true); } @@ -97,6 +83,11 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = const imageUrl = item.item_image && item.image_mime_type ? `data:${item.image_mime_type};base64,${item.item_image}` : null; + const addedByUsers = Array.isArray(item.added_by_users) + ? item.added_by_users.filter( + (name) => typeof name === "string" && name.trim().length > 0 + ) + : []; const getTimeAgo = (dateString) => { if (!dateString) return null; @@ -146,10 +137,10 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
{item.item_name}
- {item.added_by_users && item.added_by_users.length > 0 && ( + {addedByUsers.length > 0 && (
{item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `} - {item.added_by_users.join(" • ")} + {addedByUsers.join(" | ")}
)}
@@ -163,16 +154,6 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = onAddImage={handleAddImage} /> )} - - {showConfirmBuyModal && ( - - )} ); } @@ -190,8 +171,9 @@ export default memo(GroceryListItem, (prevProps, nextProps) => { prevProps.item.zone === nextProps.item.zone && prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') && prevProps.onClick === nextProps.onClick && + prevProps.onOpenBuyModal === nextProps.onOpenBuyModal && prevProps.onImageAdded === nextProps.onImageAdded && prevProps.onLongPress === nextProps.onLongPress && - prevProps.allItems?.length === nextProps.allItems?.length + prevProps.compact === nextProps.compact ); }); diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index f85df2a..02d4f7d 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -1,31 +1,117 @@ import "../../styles/components/Navbar.css"; -import { useContext } from "react"; -import { Link } from "react-router-dom"; +import { useContext, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { logoutRequest } from "../../api/auth"; import { AuthContext } from "../../context/AuthContext"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; +import HouseholdSwitcher from "../household/HouseholdSwitcher"; export default function Navbar() { + const navigate = useNavigate(); const { role, logout, username } = useContext(AuthContext); + const toast = useActionToast(); + const [showUserMenu, setShowUserMenu] = useState(false); + + const closeMenus = () => { + setShowUserMenu(false); + }; + + const handleLogout = async () => { + let loggedOutRemotely = true; + let fallbackReason = ""; + try { + await logoutRequest(); + } catch (error) { + // Clear local auth state even if server logout fails. + loggedOutRemotely = false; + fallbackReason = getApiErrorMessage(error, "Unable to end server session"); + } finally { + logout(); + closeMenus(); + if (loggedOutRemotely) { + toast.success("Logged out", "Logged out successfully"); + } else { + toast.info("Logged out locally", `Server logout failed: ${fallbackReason}`); + } + navigate("/login"); + } + }; return ( ); -} \ No newline at end of file +} diff --git a/frontend/src/components/manage/CreateJoinHousehold.jsx b/frontend/src/components/manage/CreateJoinHousehold.jsx new file mode 100644 index 0000000..a8bb221 --- /dev/null +++ b/frontend/src/components/manage/CreateJoinHousehold.jsx @@ -0,0 +1,171 @@ +import { useContext, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { HouseholdContext } from "../../context/HouseholdContext"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; +import "../../styles/components/manage/CreateJoinHousehold.css"; + +function extractInviteToken(value) { + const trimmed = value.trim(); + if (!trimmed) return null; + + const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/); + if (directMatch) return directMatch[1]; + + try { + const parsed = new URL(trimmed, window.location.origin); + const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/); + if (urlMatch) return urlMatch[1]; + } catch { + return null; + } + + return null; +} + +export default function CreateJoinHousehold({ initialMode = "create", onClose }) { + const navigate = useNavigate(); + const toast = useActionToast(); + const { createHousehold: createHouseholdWithContext } = useContext(HouseholdContext); + const [mode, setMode] = useState(initialMode === "join" ? "join" : "create"); + const [householdName, setHouseholdName] = useState(""); + const [inviteLink, setInviteLink] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + setMode(initialMode === "join" ? "join" : "create"); + setError(""); + }, [initialMode]); + + const handleCreate = async (e) => { + e.preventDefault(); + if (!householdName.trim()) return; + + setLoading(true); + setError(""); + + try { + await createHouseholdWithContext(householdName); + toast.success("Created household", `Created household ${householdName.trim()}`); + onClose(); + } catch (err) { + console.error("Failed to create household:", err); + const message = getApiErrorMessage(err, "Failed to create household"); + setError(message); + toast.error("Create household failed", `Create household failed: ${message}`); + } finally { + setLoading(false); + } + }; + + const handleJoin = async (e) => { + e.preventDefault(); + if (!inviteLink.trim()) return; + + setLoading(true); + setError(""); + + try { + const inviteToken = extractInviteToken(inviteLink); + if (!inviteToken) { + const message = "Use a household invite link like /invite/abcd1234."; + setError(message); + toast.error("Open invite link failed", message); + return; + } + + toast.info("Opening invite link", "Checking invite details"); + onClose(); + navigate(`/invite/${inviteToken}`); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Household

+ +
+ +
+ + +
+ + {error &&
{error}
} + + {mode === "create" ? ( +
+
+ + setHouseholdName(e.target.value)} + placeholder="e.g., Smith Family" + required + autoFocus + /> +
+
+ + +
+
+ ) : ( +
+
+ + setInviteLink(e.target.value)} + placeholder="https://.../invite/your-token" + required + autoFocus + /> +

+ Paste the full invite URL or a local path like /invite/your-token +

+
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/manage/ManageHousehold.jsx b/frontend/src/components/manage/ManageHousehold.jsx new file mode 100644 index 0000000..8971d3a --- /dev/null +++ b/frontend/src/components/manage/ManageHousehold.jsx @@ -0,0 +1,669 @@ +import React, { useContext, useEffect, useState } from "react"; +import { + createGroupInviteLink, + decideGroupJoinRequest, + deleteGroupInviteLink, + deleteHousehold, + getGroupInviteLinks, + getGroupJoinPolicy, + getHouseholdMembers, + getPendingGroupJoinRequests, + removeMember, + revokeGroupInviteLink, + reviveGroupInviteLink, + setGroupJoinPolicy, + updateHousehold, + updateMemberRole, +} from "../../api/households"; +import { ToggleButtonGroup } from "../common"; +import ConfirmSlideModal from "../modals/ConfirmSlideModal"; +import { AuthContext } from "../../context/AuthContext"; +import { HouseholdContext } from "../../context/HouseholdContext"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; +import "../../styles/components/manage/ManageHousehold.css"; + +const JOIN_POLICY_OPTIONS = [ + { label: "Disabled", value: "NOT_ACCEPTING" }, + { label: "Auto", value: "AUTO_ACCEPT" }, + { label: "Manual", value: "APPROVAL_REQUIRED" }, +]; + +const ROLE_METADATA = { + owner: { icon: "👑", label: "Owner" }, + admin: { icon: "🛠️", label: "Admin" }, + member: { icon: "🙂", label: "Member" }, + viewer: { icon: "👀", label: "Viewer" }, +}; + +const STATUS_METADATA = { + Active: { tone: "active", icon: "🟢" }, + Used: { tone: "used", icon: "⚪" }, + Revoked: { tone: "revoked", icon: "🔴" }, + Expired: { tone: "expired", icon: "🟠" }, +}; + +function getRequesterLabel(request) { + return ( + request.display_name?.trim() || + request.name?.trim() || + request.username?.trim() || + `User #${request.user_id}` + ); +} + +export default function ManageHousehold() { + const { userId } = useContext(AuthContext); + const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext); + const toast = useActionToast(); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [editingName, setEditingName] = useState(false); + const [newName, setNewName] = useState(""); + const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING"); + const [inviteLinks, setInviteLinks] = useState([]); + const [pendingRequests, setPendingRequests] = useState([]); + const [inviteLoading, setInviteLoading] = useState(false); + const [inviteError, setInviteError] = useState(""); + const [ttlDays, setTtlDays] = useState(7); + const [singleUseMode, setSingleUseMode] = useState("UNLIMITED"); + const [pendingDecisionId, setPendingDecisionId] = useState(null); + const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); + const [pendingRoleChange, setPendingRoleChange] = useState(null); + + const isManager = ["owner", "admin"].includes(activeHousehold?.role); + const isOwner = activeHousehold?.role === "owner"; + const isMemberOnly = activeHousehold?.role === "member"; + + useEffect(() => { + loadMembers(); + if (isManager) { + loadJoinAndInvites(); + } else { + setPendingRequests([]); + } + }, [activeHousehold?.id, isManager]); + + const loadMembers = async () => { + if (!activeHousehold?.id) return; + setLoading(true); + try { + const response = await getHouseholdMembers(activeHousehold.id); + setMembers(response.data); + } catch (error) { + console.error("Failed to load members:", error); + } finally { + setLoading(false); + } + }; + + const loadJoinAndInvites = async () => { + if (!activeHousehold?.id || !isManager) return; + setInviteLoading(true); + setInviteError(""); + try { + const [policyResponse, linksResponse, requestsResponse] = await Promise.all([ + getGroupJoinPolicy(activeHousehold.id), + getGroupInviteLinks(activeHousehold.id), + getPendingGroupJoinRequests(activeHousehold.id), + ]); + setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING"); + setInviteLinks(linksResponse.data.links || []); + setPendingRequests(requestsResponse.data.requests || []); + } catch (error) { + setInviteError(error.response?.data?.error?.message || "Failed to load invite links"); + } finally { + setInviteLoading(false); + } + }; + + const getLinkStatus = (link) => { + const now = Date.now(); + if (link.single_use && link.used_at) return "Used"; + if (link.revoked_at) return "Revoked"; + if (new Date(link.expires_at).getTime() <= now) return "Expired"; + return "Active"; + }; + + const copyTextToClipboard = async (text) => { + if (!text) return false; + + if (navigator?.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to legacy copy fallback. + } + } + + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.setAttribute("readonly", ""); + textArea.style.position = "fixed"; + textArea.style.top = "-9999px"; + textArea.style.left = "-9999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const copied = document.execCommand("copy"); + document.body.removeChild(textArea); + return copied; + } catch { + return false; + } + }; + + const copyInviteLink = async (token) => { + const inviteUrl = `${window.location.origin}/invite/${encodeURIComponent(token)}`; + const copied = await copyTextToClipboard(inviteUrl); + if (copied) { + const tokenLast4 = String(token || "").slice(-4); + toast.info("Copied invite link", `Copied invite link ending in ${tokenLast4}`); + return; + } + + toast.error( + "Copy invite link failed", + "Copy invite link failed: unable to access clipboard. Copy manually." + ); + }; + + const handleCreateInviteLink = async () => { + try { + setInviteError(""); + await createGroupInviteLink(activeHousehold.id, { + policy: joinPolicy, + singleUse: singleUseMode === "ONE_TIME", + ttlDays, + }); + await loadJoinAndInvites(); + toast.success("Created invite link", `Created invite link (${ttlDays} day TTL)`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to create invite link"); + setInviteError(message); + toast.error("Create invite link failed", `Create invite link failed: ${message}`); + } + }; + + const handleUpdateJoinPolicy = async (value) => { + try { + setInviteError(""); + await setGroupJoinPolicy(activeHousehold.id, value); + setJoinPolicyValue(value); + toast.success("Updated join policy", `Join policy set to ${value}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to update join policy"); + setInviteError(message); + toast.error("Update join policy failed", `Update join policy failed: ${message}`); + } + }; + + const handleJoinRequestDecision = async (request, decision) => { + const requesterName = getRequesterLabel(request); + setPendingDecisionId(request.id); + try { + setInviteError(""); + await decideGroupJoinRequest(activeHousehold.id, request.id, decision); + await Promise.all([loadJoinAndInvites(), loadMembers()]); + if (decision === "APPROVE") { + toast.success("Approved join request", `Approved ${requesterName}`); + } else { + toast.info("Denied join request", `Denied ${requesterName}`); + } + } catch (error) { + const message = getApiErrorMessage(error, "Failed to update join request"); + setInviteError(message); + toast.error("Join request update failed", `Join request update failed: ${message}`); + } finally { + setPendingDecisionId(null); + } + }; + + const handleRevokeInvite = async (linkId) => { + try { + setInviteError(""); + await revokeGroupInviteLink(activeHousehold.id, linkId); + await loadJoinAndInvites(); + toast.success("Revoked invite link", "Revoked invite link"); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to revoke invite link"); + setInviteError(message); + toast.error("Revoke invite link failed", `Revoke invite link failed: ${message}`); + } + }; + + const handleReviveInvite = async (linkId) => { + try { + setInviteError(""); + await reviveGroupInviteLink(activeHousehold.id, linkId, ttlDays); + await loadJoinAndInvites(); + toast.success("Revived invite link", `Revived invite link for ${ttlDays} days`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to revive invite link"); + setInviteError(message); + toast.error("Revive invite link failed", `Revive invite link failed: ${message}`); + } + }; + + const handleDeleteInvite = async (linkId) => { + if (!confirm("Delete this invite link permanently?")) return; + try { + setInviteError(""); + await deleteGroupInviteLink(activeHousehold.id, linkId); + await loadJoinAndInvites(); + toast.success("Deleted invite link", "Deleted invite link"); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to delete invite link"); + setInviteError(message); + toast.error("Delete invite link failed", `Delete invite link failed: ${message}`); + } + }; + + const handleUpdateName = async () => { + if (!newName.trim() || newName === activeHousehold.name) { + setEditingName(false); + return; + } + + try { + await updateHousehold(activeHousehold.id, newName); + await refreshHouseholds(); + toast.success("Updated household name", `Updated household name to ${newName.trim()}`); + setEditingName(false); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to update household name"); + toast.error("Update household name failed", `Update household name failed: ${message}`); + } + }; + + const handleConfirmRoleChange = async () => { + if (!pendingRoleChange) return; + + const { memberId, nextRole, memberName } = pendingRoleChange; + + try { + await updateMemberRole(activeHousehold.id, memberId, nextRole); + await Promise.all([ + loadMembers(), + nextRole === "owner" ? refreshHouseholds() : Promise.resolve(), + ]); + if (nextRole === "owner") { + toast.success("Transferred household ownership", `Transferred ownership to ${memberName}`); + } else { + toast.success("Updated member role", `Updated role for ${memberName} to ${nextRole}`); + } + setPendingRoleChange(null); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to update member role"); + toast.error("Update member role failed", `Update member role failed: ${message}`); + } + }; + + const handleUpdateRole = (memberId, nextRole, memberName) => { + if (!nextRole) return; + + setPendingRoleChange({ memberId, nextRole, memberName }); + }; + + const handleRemoveMember = async (memberId, username) => { + if (!confirm(`Remove ${username} from this household?`)) return; + + try { + await removeMember(activeHousehold.id, memberId); + await loadMembers(); + toast.success("Removed member", `Removed member ${username}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to remove member"); + toast.error("Remove member failed", `Remove member failed: ${message}`); + } + }; + + const handleDeleteHousehold = async () => { + if (!confirm(`Delete "${activeHousehold.name}"? This will delete all lists and data. This cannot be undone.`)) return; + if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return; + + try { + const householdName = activeHousehold.name; + await deleteHousehold(activeHousehold.id); + await refreshHouseholds(); + toast.success("Deleted household", `Deleted household ${householdName}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to delete household"); + toast.error("Delete household failed", `Delete household failed: ${message}`); + } + }; + + const handleLeaveHousehold = async () => { + if (!activeHousehold?.id) return; + + try { + const householdName = activeHousehold.name; + await removeMember(activeHousehold.id, parseInt(userId, 10)); + setIsLeaveModalOpen(false); + await refreshHouseholds(); + toast.success("Left household", `Left household ${householdName}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to leave household"); + toast.error("Leave household failed", `Leave household failed: ${message}`); + } + }; + + const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length; + const memberCount = members.filter((member) => member.role === "member").length; + + return ( +
+
+
+
+

Household

+

Identity

+

+ Keep the household name crisp and easy to recognize across invites and shared lists. +

+
+
+ {editingName ? ( +
+ setNewName(e.target.value)} + placeholder="Household name" + autoFocus + /> + + +
+ ) : ( +
+
+

{activeHousehold.name}

+
+ 🏠 {members.length} people + 🛡️ {managerCount} managers + 🛒 {memberCount} shoppers +
+
+ {isManager && ( + + )} +
+ )} +
+ + {isManager && ( +
+
+
+

Entry Rules

+

Invite Links

+

+ Decide how new people can enter, review manual approvals, then create invite links for the flow you want. +

+
+
+ {inviteError &&

{inviteError}

} + + ({ + ...option, + disabled: inviteLoading, + }))} + onChange={handleUpdateJoinPolicy} + /> + +
+ Pending approvals + {pendingRequests.length} +
+ + {inviteLoading ? ( +

Loading invite settings...

+ ) : pendingRequests.length === 0 ? ( +

No pending join requests right now.

+ ) : ( +
+ {pendingRequests.map((request) => { + const requesterLabel = getRequesterLabel(request); + const isBusy = pendingDecisionId === request.id; + return ( +
+
+
+

{requesterLabel}

+ 🕒 Pending +
+

+ @{request.username} • Requested {new Date(request.created_at).toLocaleString()} +

+
+
+ + +
+
+ ); + })} +
+ )} + +
+ + + +
+ + {inviteLoading ? ( +

Loading invite links...

+ ) : inviteLinks.length === 0 ? ( +

No invite links yet.

+ ) : ( +
+ {inviteLinks.map((link) => { + const status = getLinkStatus(link); + const isActive = status === "Active"; + const statusMeta = STATUS_METADATA[status] || STATUS_METADATA.Active; + return ( +
+
+
+

Invite ending in {String(link.token).slice(-4)}

+ + {statusMeta.icon} {status} + +
+

+ Policy: {link.policy} • Expires {new Date(link.expires_at).toLocaleString()} +

+
+
+ + {isActive ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ )} +
+ )} + +
+
+
+

People

+

Members ({members.length})

+

+ Role badges and compact actions make it easier to see who runs the household and who just shops. +

+
+
+ {loading ? ( +

Loading members...

+ ) : ( +
+ {members.map((member) => { + const roleMeta = ROLE_METADATA[member.role] || { icon: "👤", label: member.role }; + const isSelf = member.id === parseInt(userId, 10); + + return ( +
+
+ +
+
+ + {roleMeta.icon} {roleMeta.label} + + {isSelf && ✨ You} +
+ {member.username} + ID #{member.id} +
+
+ {isManager && !isSelf && member.role !== "owner" && ( +
+ {isOwner && ( + + )} + + +
+ )} +
+ ); + })} +
+ )} +
+ + {(isManager || isMemberOnly) && ( +
+
+
+

Final Actions

+

Danger Zone

+

+ {isMemberOnly + ? "Leaving removes your access to this household." + : "Deleting a household is permanent and will delete all lists, items, and history."} +

+
+ {isMemberOnly ? ( + + ) : ( + + )} +
+
+ )} + + setIsLeaveModalOpen(false)} + onConfirm={handleLeaveHousehold} + /> + + setPendingRoleChange(null)} + onConfirm={handleConfirmRoleChange} + /> +
+ ); +} diff --git a/frontend/src/components/manage/ManageStores.jsx b/frontend/src/components/manage/ManageStores.jsx new file mode 100644 index 0000000..1f73f23 --- /dev/null +++ b/frontend/src/components/manage/ManageStores.jsx @@ -0,0 +1,184 @@ +import { useContext, useEffect, useState } from "react"; +import { + addStoreToHousehold, + getAllStores, + removeStoreFromHousehold, + setDefaultStore +} from "../../api/stores"; +import StoreAvailableItemsManager from "./StoreAvailableItemsManager"; +import { HouseholdContext } from "../../context/HouseholdContext"; +import { StoreContext } from "../../context/StoreContext"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; +import "../../styles/components/manage/ManageStores.css"; +import "../../styles/components/manage/StoreAvailableItemsManager.css"; + +export default function ManageStores() { + const { activeHousehold } = useContext(HouseholdContext); + const { stores: householdStores, refreshStores } = useContext(StoreContext); + const toast = useActionToast(); + const [allStores, setAllStores] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddStore, setShowAddStore] = useState(false); + + const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); + + useEffect(() => { + loadAllStores(); + }, []); + + const loadAllStores = async () => { + setLoading(true); + try { + const response = await getAllStores(); + setAllStores(response.data); + } catch (error) { + console.error("Failed to load stores:", error); + } finally { + setLoading(false); + } + }; + + const handleAddStore = async (storeId) => { + const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; + try { + console.log("Adding store with ID:", storeId); + await addStoreToHousehold(activeHousehold.id, storeId, false); + await refreshStores(); + toast.success("Added store", `Added store ${storeName}`); + setShowAddStore(false); + } catch (error) { + console.error("Failed to add store:", error); + const message = getApiErrorMessage(error, "Failed to add store"); + toast.error("Add store failed", `Add store failed: ${message}`); + } + }; + + const handleRemoveStore = async (storeId, storeName) => { + if (!confirm(`Remove ${storeName} from this household?`)) return; + + try { + await removeStoreFromHousehold(activeHousehold.id, storeId); + await refreshStores(); + toast.success("Removed store", `Removed store ${storeName}`); + } catch (error) { + console.error("Failed to remove store:", error); + const message = getApiErrorMessage(error, "Failed to remove store"); + toast.error("Remove store failed", `Remove store failed: ${message}`); + } + }; + + const handleSetDefault = async (storeId) => { + const storeName = + householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; + try { + await setDefaultStore(activeHousehold.id, storeId); + await refreshStores(); + toast.success("Updated default store", `Default store set to ${storeName}`); + } catch (error) { + console.error("Failed to set default store:", error); + const message = getApiErrorMessage(error, "Failed to set default store"); + toast.error("Set default store failed", `Set default store failed: ${message}`); + } + }; + + const availableStores = allStores.filter( + store => !householdStores.some(hs => hs.id === store.id) + ); + + return ( +
+ {/* Current Stores Section */} +
+

Your Stores ({householdStores.length})

+

+ Use each store card's Manage Items button to edit or delete the household/store item list. +

+ {!isAdmin && ( +

+ Only household owners and admins can manage store item catalogs. +

+ )} + {householdStores.length === 0 ? ( +

No stores added yet.

+ ) : ( +
+ {householdStores.map((store) => ( +
+
+

{store.name}

+ {store.location &&

{store.location}

} +
+ {isAdmin && ( +
+ {!store.is_default && ( + + )} + +
+ )} + +
+ ))} +
+ )} +
+ + {/* Add Store Section */} + {isAdmin && ( +
+

Add Store

+ {!showAddStore ? ( + + ) : ( +
+ + {loading ? ( +

Loading stores...

+ ) : availableStores.length === 0 ? ( +

All available stores have been added.

+ ) : ( +
+ {availableStores.map((store) => ( +
+
+

{store.name}

+ {store.location &&

{store.location}

} +
+ +
+ ))} +
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/manage/StoreAvailableItemsManager.jsx b/frontend/src/components/manage/StoreAvailableItemsManager.jsx new file mode 100644 index 0000000..a886fed --- /dev/null +++ b/frontend/src/components/manage/StoreAvailableItemsManager.jsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useState } from "react"; +import { + deleteAvailableItem, + getAvailableItems, + updateAvailableItem, +} from "../../api/availableItems"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; +import AvailableItemEditorModal from "../modals/AvailableItemEditorModal"; +import ConfirmSlideModal from "../modals/ConfirmSlideModal"; + +function itemImageSource(item) { + if (!item?.item_image) { + return null; + } + + const mimeType = item.image_mime_type || "image/jpeg"; + return `data:${mimeType};base64,${item.item_image}`; +} + +export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) { + const toast = useActionToast(); + const [isOpen, setIsOpen] = useState(false); + const [items, setItems] = useState([]); + const [catalogReady, setCatalogReady] = useState(true); + const [catalogMessage, setCatalogMessage] = useState(""); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [editorItem, setEditorItem] = useState(null); + const [showEditor, setShowEditor] = useState(false); + const [pendingDeleteItem, setPendingDeleteItem] = useState(null); + + const loadItems = useCallback(async (search = query) => { + if (!householdId || !store?.id) { + setItems([]); + return; + } + + setLoading(true); + try { + const response = await getAvailableItems(householdId, store.id, search); + setItems(response.data.items || []); + setCatalogReady(response.data.catalog_ready !== false); + setCatalogMessage(response.data.message || ""); + } catch (error) { + console.error("Failed to load store items:", error); + setCatalogReady(false); + setCatalogMessage("Store item management is unavailable right now."); + const message = getApiErrorMessage(error, "Failed to load store items"); + toast.error("Load store items failed", `Load store items failed: ${message}`); + } finally { + setLoading(false); + } + }, [householdId, query, store?.id, toast]); + + useEffect(() => { + if (!isOpen) { + return; + } + + loadItems(query); + }, [isOpen, query, loadItems]); + + const closeManager = () => { + setIsOpen(false); + setPendingDeleteItem(null); + }; + + const handleUpdate = async (payload) => { + if (!catalogReady) { + toast.info( + "Store item management unavailable", + catalogMessage || "Store item management is unavailable until the latest database migration is applied." + ); + return; + } + + try { + await updateAvailableItem(householdId, store.id, editorItem.item_id, payload); + toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`); + setShowEditor(false); + setEditorItem(null); + await loadItems(query); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to update store item"); + toast.error("Update store item failed", `Update store item failed: ${message}`); + throw error; + } + }; + + const handleDeleteConfirm = async () => { + if (!pendingDeleteItem) { + return; + } + + try { + await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id); + toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`); + setPendingDeleteItem(null); + await loadItems(query); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to delete store item"); + toast.error("Delete store item failed", `Delete store item failed: ${message}`); + } + }; + + if (!isAdmin) { + return null; + } + + return ( + <> + + + {isOpen ? ( +
+
event.stopPropagation()}> +
+
+

{store.name} Items

+

Manage the household/store items used for suggestions and store defaults.

+
+ +
+ + {!catalogReady ? ( +

+ {catalogMessage || "Store item management is unavailable until the latest database migration is applied."} +

+ ) : null} + +
+ setQuery(event.target.value)} + placeholder="Search household/store items" + disabled={!catalogReady} + /> +
+ +
+ {!catalogReady ? ( +

Run the latest database migrations to enable store item management.

+ ) : loading ? ( +

Loading store items...

+ ) : items.length === 0 ? ( +

No household items found for this store yet.

+ ) : ( +
+ +
+ {items.map((item) => { + const imageSrc = itemImageSource(item); + const details = [item.item_type, item.item_group, item.zone].filter(Boolean); + + return ( +
+
+ Item +
+ {imageSrc ? ( + + ) : ( + + {item.item_name?.slice(0, 1).toUpperCase() || "?"} + + )} +
+ {item.item_name} +
+
+
+ +
+ Store Defaults + + {details.join(" | ") || "No store defaults set"} + +
+ +
+ Actions +
+ + +
+
+
+ ); + })} +
+
+ )} +
+
+
+ ) : null} + + { + setShowEditor(false); + setEditorItem(null); + }} + onSave={handleUpdate} + /> + + setPendingDeleteItem(null)} + onConfirm={handleDeleteConfirm} + /> + + ); +} diff --git a/frontend/src/components/manage/index.js b/frontend/src/components/manage/index.js new file mode 100644 index 0000000..5ce1ee0 --- /dev/null +++ b/frontend/src/components/manage/index.js @@ -0,0 +1,4 @@ +export { default as CreateJoinHousehold } from './CreateJoinHousehold'; +export { default as ManageHousehold } from './ManageHousehold'; +export { default as ManageStores } from './ManageStores'; + diff --git a/frontend/src/components/modals/AddItemWithDetailsModal.jsx b/frontend/src/components/modals/AddItemWithDetailsModal.jsx index b9e31fd..fab0ab9 100644 --- a/frontend/src/components/modals/AddItemWithDetailsModal.jsx +++ b/frontend/src/components/modals/AddItemWithDetailsModal.jsx @@ -1,9 +1,11 @@ import { useState } from "react"; import "../../styles/components/AddItemWithDetailsModal.css"; import ClassificationSection from "../forms/ClassificationSection"; +import useActionToast from "../../hooks/useActionToast"; import ImageUploadSection from "../forms/ImageUploadSection"; export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { + const toast = useActionToast(); const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); const [itemType, setItemType] = useState(""); @@ -30,15 +32,15 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o }; const handleConfirm = () => { - // Validate classification if provided if (itemType && !itemGroup) { - alert("Please select an item group"); + toast.error("Add item failed", `Add item failed: Select an item group for ${itemName}`); return; } - const classification = itemType ? { + const hasClassificationDetails = Boolean(itemType || itemGroup || zone); + const classification = hasClassificationDetails ? { item_type: itemType, - item_group: itemGroup, + item_group: itemGroup || null, zone: zone || null } : null; diff --git a/frontend/src/components/modals/AssignItemForModal.jsx b/frontend/src/components/modals/AssignItemForModal.jsx new file mode 100644 index 0000000..da60f6d --- /dev/null +++ b/frontend/src/components/modals/AssignItemForModal.jsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import "../../styles/components/AssignItemForModal.css"; + +function getMemberLabel(member) { + return member.display_name || member.name || member.username || `User ${member.id}`; +} + +function getMemberOptionLabel(member, maxLength = 28) { + const label = getMemberLabel(member); + if (label.length <= maxLength) return label; + return `${label.slice(0, maxLength - 3)}...`; +} + +export default function AssignItemForModal({ + isOpen, + members, + onCancel, + onConfirm +}) { + const [selectedUserId, setSelectedUserId] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + const hasMembers = members.length > 0; + const selectedMember = useMemo( + () => members.find((member) => String(member.id) === String(selectedUserId)) || null, + [members, selectedUserId] + ); + + const updateDropdownPosition = useCallback(() => { + if (!triggerRef.current) return; + + const rect = triggerRef.current.getBoundingClientRect(); + const viewportPadding = 16; + const menuGap = 6; + const width = Math.min(rect.width, window.innerWidth - (2 * viewportPadding)); + const left = Math.min( + Math.max(viewportPadding, rect.left), + window.innerWidth - width - viewportPadding + ); + const availableBelow = window.innerHeight - rect.bottom - menuGap - viewportPadding; + const availableAbove = rect.top - menuGap - viewportPadding; + const shouldOpenAbove = availableBelow < 140 && availableAbove > availableBelow; + const maxHeight = Math.max( + 120, + Math.min(240, Math.floor(shouldOpenAbove ? availableAbove : availableBelow)) + ); + + setDropdownStyle({ + left: `${Math.round(left)}px`, + width: `${Math.round(width)}px`, + maxHeight: `${maxHeight}px`, + top: shouldOpenAbove ? "auto" : `${Math.round(rect.bottom + menuGap)}px`, + bottom: shouldOpenAbove ? `${Math.round(window.innerHeight - rect.top + menuGap)}px` : "auto", + }); + }, []); + + useEffect(() => { + if (!isOpen) return; + setSelectedUserId(members[0] ? String(members[0].id) : ""); + setIsDropdownOpen(false); + setDropdownStyle(null); + }, [isOpen, members]); + + useEffect(() => { + if (!isOpen) return undefined; + + const handleEscape = (event) => { + if (event.key === "Escape") { + if (isDropdownOpen) { + setIsDropdownOpen(false); + } else { + onCancel(); + } + } + }; + + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [isDropdownOpen, isOpen, onCancel]); + + useEffect(() => { + if (!isOpen || !isDropdownOpen) return undefined; + + const handlePointerDown = (event) => { + const clickedTrigger = triggerRef.current?.contains(event.target); + const clickedMenu = menuRef.current?.contains(event.target); + + if (!clickedTrigger && !clickedMenu) { + setIsDropdownOpen(false); + } + }; + + window.addEventListener("pointerdown", handlePointerDown); + return () => window.removeEventListener("pointerdown", handlePointerDown); + }, [isDropdownOpen, isOpen]); + + useEffect(() => { + if (!isOpen || !isDropdownOpen) return undefined; + + updateDropdownPosition(); + + const handleViewportChange = () => { + updateDropdownPosition(); + }; + + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + + return () => { + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [isDropdownOpen, isOpen, updateDropdownPosition]); + + if (!isOpen) return null; + + const handleConfirm = () => { + if (!selectedMember) return; + onConfirm(selectedMember.id); + }; + + const handleToggleDropdown = () => { + if (isDropdownOpen) { + setIsDropdownOpen(false); + return; + } + + updateDropdownPosition(); + setIsDropdownOpen(true); + }; + + const dropdownMenu = isDropdownOpen && dropdownStyle + ? createPortal( +
event.stopPropagation()} + > + {members.map((member) => { + const memberId = String(member.id); + const isSelected = memberId === String(selectedUserId); + + return ( + + ); + })} +
, + document.body + ) + : null; + + return ( +
+
event.stopPropagation()}> +

Add Item For Someone Else

+ + {hasMembers ? ( +
+ +
+ +
+
+ ) : ( +

+ No other household members are available. +

+ )} + +
+ + +
+
+ {dropdownMenu} +
+ ); +} diff --git a/frontend/src/components/modals/AvailableItemEditorModal.jsx b/frontend/src/components/modals/AvailableItemEditorModal.jsx new file mode 100644 index 0000000..582a0c6 --- /dev/null +++ b/frontend/src/components/modals/AvailableItemEditorModal.jsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import ClassificationSection from "../forms/ClassificationSection"; +import ImageUploadSection from "../forms/ImageUploadSection"; +import useActionToast from "../../hooks/useActionToast"; +import "../../styles/components/AvailableItemEditorModal.css"; + +function buildPreview(item) { + if (!item?.item_image) { + return null; + } + + const mimeType = item.image_mime_type || "image/jpeg"; + return `data:${mimeType};base64,${item.item_image}`; +} + +export default function AvailableItemEditorModal({ isOpen, item = null, onCancel, onSave }) { + const toast = useActionToast(); + const [itemName, setItemName] = useState(""); + const [itemType, setItemType] = useState(""); + const [itemGroup, setItemGroup] = useState(""); + const [zone, setZone] = useState(""); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [removeImage, setRemoveImage] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isOpen) { + return; + } + + setItemName(item?.item_name || ""); + setItemType(item?.item_type || ""); + setItemGroup(item?.item_group || ""); + setZone(item?.zone || ""); + setSelectedImage(null); + setImagePreview(buildPreview(item)); + setRemoveImage(false); + }, [isOpen, item]); + + if (!isOpen) { + return null; + } + + const handleItemTypeChange = (nextType) => { + setItemType(nextType); + setItemGroup(""); + }; + + const handleImageChange = (file) => { + setSelectedImage(file); + setRemoveImage(false); + + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + }; + + const handleImageRemove = () => { + setSelectedImage(null); + setImagePreview(null); + setRemoveImage(Boolean(item?.item_image)); + }; + + const handleSubmit = async () => { + if (!itemName.trim()) { + toast.error("Save available item failed", "Save available item failed: Item name is required"); + return; + } + + if (itemType && !itemGroup) { + toast.error( + "Save available item failed", + `Save available item failed: Select an item group for ${itemName.trim()}` + ); + return; + } + + setSaving(true); + try { + await onSave({ + itemName: itemName.trim(), + classification: itemType || itemGroup || zone + ? { + item_type: itemType || null, + item_group: itemGroup || null, + zone: zone || null, + } + : null, + imageFile: selectedImage, + removeImage, + }); + } finally { + setSaving(false); + } + }; + + return ( +
+
event.stopPropagation()}> +

+ {item ? `Edit ${item.item_name}` : "Edit Store Item"} +

+

+ Save store-specific defaults for this household/store item. +

+ +
+ + setItemName(event.target.value)} + placeholder="Enter item name" + disabled={Boolean(item)} + /> +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/modals/ConfirmBuyModal.jsx b/frontend/src/components/modals/ConfirmBuyModal.jsx index 2029766..57d093a 100644 --- a/frontend/src/components/modals/ConfirmBuyModal.jsx +++ b/frontend/src/components/modals/ConfirmBuyModal.jsx @@ -9,45 +9,54 @@ export default function ConfirmBuyModal({ onNavigate }) { const [quantity, setQuantity] = useState(item.quantity); + const [isSubmitting, setIsSubmitting] = useState(false); const maxQuantity = item.quantity; - // Update quantity when item changes (navigation) useEffect(() => { setQuantity(item.quantity); + setIsSubmitting(false); }, [item.id, item.quantity]); - // Find current index and check for prev/next - const currentIndex = allItems.findIndex(i => i.id === item.id); + const currentIndex = allItems.findIndex((listItem) => listItem.id === item.id); const hasPrev = currentIndex > 0; const hasNext = currentIndex < allItems.length - 1; const handleIncrement = () => { - if (quantity < maxQuantity) { - setQuantity(prev => prev + 1); + if (!isSubmitting && quantity < maxQuantity) { + setQuantity((prev) => prev + 1); } }; const handleDecrement = () => { - if (quantity > 1) { - setQuantity(prev => prev - 1); + if (!isSubmitting && quantity > 1) { + setQuantity((prev) => prev - 1); } }; - const handleConfirm = () => { - onConfirm(quantity); + const handleConfirm = async () => { + if (isSubmitting) return; + + setIsSubmitting(true); + try { + await onConfirm(quantity); + } finally { + setIsSubmitting(false); + } }; const handlePrev = () => { + if (isSubmitting) return; + if (hasPrev && onNavigate) { - const prevItem = allItems[currentIndex - 1]; - onNavigate(prevItem); + onNavigate(allItems[currentIndex - 1]); } }; const handleNext = () => { + if (isSubmitting) return; + if (hasNext && onNavigate) { - const nextItem = allItems[currentIndex + 1]; - onNavigate(nextItem); + onNavigate(allItems[currentIndex + 1]); } }; @@ -56,8 +65,15 @@ export default function ConfirmBuyModal({ : null; return ( -
-
e.stopPropagation()}> +
{ + if (!isSubmitting) { + onCancel(); + } + }} + > +
event.stopPropagation()}>
{item.zone &&
{item.zone}
}

{item.item_name}

@@ -67,27 +83,27 @@ export default function ConfirmBuyModal({
{imageUrl ? ( {item.item_name} ) : ( -
📦
+
[ ]
)}
@@ -96,9 +112,9 @@ export default function ConfirmBuyModal({ = maxQuantity} + disabled={quantity >= maxQuantity || isSubmitting} > + @@ -117,11 +133,11 @@ export default function ConfirmBuyModal({
- -
diff --git a/frontend/src/components/modals/ConfirmSlideModal.jsx b/frontend/src/components/modals/ConfirmSlideModal.jsx new file mode 100644 index 0000000..f924701 --- /dev/null +++ b/frontend/src/components/modals/ConfirmSlideModal.jsx @@ -0,0 +1,191 @@ +import { useEffect, useRef, useState } from "react"; +import "../../styles/components/ConfirmSlideModal.css"; + +const HANDLE_SIZE = 40; + +export default function ConfirmSlideModal({ + isOpen, + title, + description, + confirmLabel = "Confirm", + onClose, + onConfirm +}) { + const trackRef = useRef(null); + const endFlashTimeoutRef = useRef(null); + const reachedEndRef = useRef(false); + + const [dragX, setDragX] = useState(0); + const [dragging, setDragging] = useState(false); + const [isAtEnd, setIsAtEnd] = useState(false); + const [endFlash, setEndFlash] = useState(false); + + const getDragPositionFromClientX = (clientX) => { + const track = trackRef.current; + if (!track) return 0; + + const rect = track.getBoundingClientRect(); + return Math.min( + Math.max(0, clientX - rect.left - HANDLE_SIZE / 2), + rect.width - HANDLE_SIZE + ); + }; + + const isEndPosition = (position) => { + const track = trackRef.current; + if (!track) return false; + const maxDrag = track.clientWidth - HANDLE_SIZE; + const endTolerancePx = 1; + return position >= maxDrag - endTolerancePx; + }; + + const triggerEndFeedback = () => { + setEndFlash(true); + if (endFlashTimeoutRef.current) { + clearTimeout(endFlashTimeoutRef.current); + } + endFlashTimeoutRef.current = setTimeout(() => setEndFlash(false), 140); + + if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") { + navigator.vibrate(16); + } + }; + + const handlePointerDown = (event) => { + event.preventDefault(); + setDragging(true); + reachedEndRef.current = false; + setIsAtEnd(false); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handlePointerMove = (event) => { + if (!dragging) return; + const next = getDragPositionFromClientX(event.clientX); + const nextAtEnd = isEndPosition(next); + + setDragX(next); + setIsAtEnd((prev) => (prev === nextAtEnd ? prev : nextAtEnd)); + + if (nextAtEnd && !reachedEndRef.current) { + reachedEndRef.current = true; + triggerEndFeedback(); + } + if (!nextAtEnd) { + reachedEndRef.current = false; + } + }; + + const handlePointerUp = (event) => { + if (!dragging) return; + + setDragging(false); + event.currentTarget.releasePointerCapture(event.pointerId); + + const releaseX = getDragPositionFromClientX(event.clientX); + const releaseAtEnd = isEndPosition(releaseX); + + setIsAtEnd((prev) => (prev ? false : prev)); + + if (releaseAtEnd && !reachedEndRef.current) { + triggerEndFeedback(); + } + + setDragX(0); + if (releaseAtEnd) { + onConfirm(); + } + }; + + const handlePointerCancel = (event) => { + if (!dragging) return; + + setDragging(false); + event.currentTarget.releasePointerCapture(event.pointerId); + setIsAtEnd((prev) => (prev ? false : prev)); + setDragX(0); + }; + + useEffect(() => { + return () => { + if (endFlashTimeoutRef.current) { + clearTimeout(endFlashTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isOpen) return undefined; + + const handleKeyDown = (event) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + useEffect(() => { + if (!isOpen) { + setDragX(0); + setDragging(false); + setIsAtEnd(false); + setEndFlash(false); + reachedEndRef.current = false; + } + }, [isOpen]); + + if (!isOpen) return null; + + const isActive = isAtEnd || endFlash; + + return ( +
+
event.stopPropagation()}> +

{title}

+ {description ?

{description}

: null} + +
+
Slide to confirm
+
+
+
+ release +
+ +
+
+ +
+ {confirmLabel} + +
+
+
+ ); +} diff --git a/frontend/src/components/modals/EditItemModal.jsx b/frontend/src/components/modals/EditItemModal.jsx index b1624a8..b56b236 100644 --- a/frontend/src/components/modals/EditItemModal.jsx +++ b/frontend/src/components/modals/EditItemModal.jsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications"; +import useActionToast from "../../hooks/useActionToast"; import "../../styles/components/EditItemModal.css"; import AddImageModal from "./AddImageModal"; export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) { + const toast = useActionToast(); const [itemName, setItemName] = useState(item.item_name || ""); const [quantity, setQuantity] = useState(item.quantity || 1); const [itemType, setItemType] = useState(""); @@ -12,49 +14,54 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) const [loading, setLoading] = useState(false); const [showImageModal, setShowImageModal] = useState(false); - // Load existing classification useEffect(() => { if (item.classification) { setItemType(item.classification.item_type || ""); setItemGroup(item.classification.item_group || ""); setZone(item.classification.zone || ""); + return; } + + setItemType(""); + setItemGroup(""); + setZone(""); }, [item]); const handleItemTypeChange = (newType) => { setItemType(newType); - setItemGroup(""); // Reset group when type changes + setItemGroup(""); }; const handleSave = async () => { if (!itemName.trim()) { - alert("Item name is required"); + toast.error("Save item failed", "Save item failed: Item name is required"); return; } if (quantity < 1) { - alert("Quantity must be at least 1"); + toast.error("Save item failed", "Save item failed: Quantity must be at least 1"); return; } - // If classification fields are filled, validate them if (itemType && !itemGroup) { - alert("Please select an item group"); + toast.error("Save item failed", `Save item failed: Select an item group for ${itemName}`); return; } setLoading(true); try { - const classification = itemType ? { - item_type: itemType, - item_group: itemGroup, - zone: zone || null - } : null; + const hasClassificationDetails = Boolean(itemType || itemGroup || zone); + const classification = hasClassificationDetails + ? { + item_type: itemType, + item_group: itemGroup || null, + zone: zone || null + } + : null; await onSave(item.id, itemName, quantity, classification); } catch (error) { console.error("Failed to save:", error); - alert("Failed to save changes"); } finally { setLoading(false); } @@ -63,21 +70,22 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) const handleImageUpload = async (imageFile) => { if (onImageUpdate) { try { - await onImageUpdate(item.id, itemName, quantity, imageFile); + await onImageUpdate(item.id, itemName, quantity, imageFile, "edit_modal"); setShowImageModal(false); } catch (error) { console.error("Failed to upload image:", error); - alert("Failed to upload image"); + const message = error?.response?.data?.error?.message || error?.response?.data?.message || "Failed to upload image"; + toast.error("Upload image failed", `Upload image failed: ${message}`); } } }; const incrementQuantity = () => { - setQuantity(prev => prev + 1); + setQuantity((prev) => prev + 1); }; const decrementQuantity = () => { - setQuantity(prev => Math.max(1, prev - 1)); + setQuantity((prev) => Math.max(1, prev - 1)); }; const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : []; @@ -87,7 +95,6 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
e.stopPropagation()}>

Edit Item

- {/* Item Name - no label */} - {/* Quantity Control - like AddItemForm */}
- {/* Inline Classification Fields */}
@@ -183,7 +188,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) disabled={loading} type="button" > - {item.item_image ? "🖼️ Change Image" : "📷 Set Image"} + {item.item_image ? "Change Image" : "Set Image"}
diff --git a/frontend/src/components/modals/index.js b/frontend/src/components/modals/index.js index ab72dac..aca8d4c 100644 --- a/frontend/src/components/modals/index.js +++ b/frontend/src/components/modals/index.js @@ -1,7 +1,9 @@ // Barrel export for modal components export { default as AddImageModal } from './AddImageModal.jsx'; export { default as AddItemWithDetailsModal } from './AddItemWithDetailsModal.jsx'; +export { default as AssignItemForModal } from './AssignItemForModal.jsx'; export { default as ConfirmBuyModal } from './ConfirmBuyModal.jsx'; +export { default as ConfirmSlideModal } from './ConfirmSlideModal.jsx'; export { default as EditItemModal } from './EditItemModal.jsx'; export { default as ImageModal } from './ImageModal.jsx'; export { default as ImageUploadModal } from './ImageUploadModal.jsx'; diff --git a/frontend/src/components/store/StoreTabs.jsx b/frontend/src/components/store/StoreTabs.jsx new file mode 100644 index 0000000..4da17e3 --- /dev/null +++ b/frontend/src/components/store/StoreTabs.jsx @@ -0,0 +1,34 @@ +import { useContext } from 'react'; +import { StoreContext } from '../../context/StoreContext'; +import '../../styles/components/StoreTabs.css'; + +export default function StoreTabs() { + const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext); + + if (!stores || stores.length === 0) { + return ( +
+
+ No stores available for this household +
+
+ ); + } + + return ( +
+
+ {stores.map(store => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/constants/roles.js b/frontend/src/constants/roles.js index e5ef6db..2f1a0d6 100644 --- a/frontend/src/constants/roles.js +++ b/frontend/src/constants/roles.js @@ -2,5 +2,7 @@ export const ROLES = { VIEWER: "viewer", EDITOR: "editor", ADMIN: "admin", + SYSTEM_ADMIN: "system_admin", + USER: "user", UP_TO_ADMIN: ["viewer", "editor", "admin"], }; \ No newline at end of file diff --git a/frontend/src/context/ActionToastContext.jsx b/frontend/src/context/ActionToastContext.jsx new file mode 100644 index 0000000..7846f99 --- /dev/null +++ b/frontend/src/context/ActionToastContext.jsx @@ -0,0 +1,105 @@ +import { createContext, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const MAX_ACTION_TOASTS = 5; +const DEFAULT_DURATION_MS = { + success: 3500, + info: 3500, + error: 5000, +}; + +export const ActionToastContext = createContext(null); + +function createToastId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `toast_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +export function ActionToastProvider({ children }) { + const [toasts, setToasts] = useState([]); + const timerByIdRef = useRef(new Map()); + + const dismiss = useCallback((id) => { + const timer = timerByIdRef.current.get(id); + if (timer) { + clearTimeout(timer); + timerByIdRef.current.delete(id); + } + + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const pushToast = useCallback( + (variant, title, message, options = {}) => { + const id = createToastId(); + const durationMs = options.durationMs ?? DEFAULT_DURATION_MS[variant] ?? 3500; + const nextToast = { + id, + variant, + title: String(title || ""), + message: String(message || ""), + createdAt: Date.now(), + durationMs, + }; + + setToasts((prev) => { + const next = [...prev, nextToast]; + if (next.length > MAX_ACTION_TOASTS) { + const oldest = next.shift(); + if (oldest) { + const timer = timerByIdRef.current.get(oldest.id); + if (timer) { + clearTimeout(timer); + timerByIdRef.current.delete(oldest.id); + } + } + } + return next; + }); + + if (durationMs > 0) { + const timer = setTimeout(() => dismiss(id), durationMs); + timerByIdRef.current.set(id, timer); + } + + return id; + }, + [dismiss] + ); + + const success = useCallback( + (title, message, options) => pushToast("success", title, message, options), + [pushToast] + ); + const error = useCallback( + (title, message, options) => pushToast("error", title, message, options), + [pushToast] + ); + const info = useCallback( + (title, message, options) => pushToast("info", title, message, options), + [pushToast] + ); + + useEffect(() => { + return () => { + for (const timer of timerByIdRef.current.values()) { + clearTimeout(timer); + } + timerByIdRef.current.clear(); + }; + }, []); + + const value = useMemo( + () => ({ + toasts, + success, + error, + info, + dismiss, + }), + [toasts, success, error, info, dismiss] + ); + + return {children}; +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index e660f4b..ca3627c 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -2,36 +2,49 @@ import { createContext, useState } from 'react'; export const AuthContext = createContext({ token: null, + userId: null, role: null, username: null, login: () => { }, logout: () => { }, }); -export const AuthProvider = ({ children }) => { - const [token, setToken] = useState(localStorage.getItem('token') || null); - const [role, setRole] = useState(localStorage.getItem('role') || null); - const [username, setUsername] = useState(localStorage.getItem('username') || null); +export const AuthProvider = ({ children }) => { + const [token, setToken] = useState(localStorage.getItem('token') || null); + const [userId, setUserId] = useState(localStorage.getItem('userId') || null); + const [role, setRole] = useState(localStorage.getItem('role') || null); + const [username, setUsername] = useState(localStorage.getItem('username') || null); + + const clearAuthStorage = () => { + localStorage.removeItem("token"); + localStorage.removeItem("userId"); + localStorage.removeItem("role"); + localStorage.removeItem("username"); + }; - const login = (data) => { - localStorage.setItem('token', data.token); - localStorage.setItem('role', data.role); - localStorage.setItem('username', data.username); - setToken(data.token); - setRole(data.role); - setUsername(data.username); - }; - - const logout = () => { - localStorage.clear(); - - setToken(null); + const login = (data) => { + localStorage.setItem('token', data.token); + localStorage.setItem('userId', data.userId); + localStorage.setItem('role', data.role); + localStorage.setItem('username', data.username); + setToken(data.token); + setUserId(data.userId); + setRole(data.role); + setUsername(data.username); + }; + + const logout = () => { + clearAuthStorage(); + + setToken(null); + setUserId(null); setRole(null); setUsername(null); }; const value = { token, + userId, role, username, login, @@ -43,4 +56,4 @@ export const AuthProvider = ({ children }) => { {children} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/context/HouseholdContext.jsx b/frontend/src/context/HouseholdContext.jsx new file mode 100644 index 0000000..cedda4f --- /dev/null +++ b/frontend/src/context/HouseholdContext.jsx @@ -0,0 +1,133 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households'; +import { AuthContext } from './AuthContext'; + +const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId'; + +export const HouseholdContext = createContext({ + households: [], + activeHousehold: null, + loading: false, + hasLoaded: false, + error: null, + setActiveHousehold: () => { }, + refreshHouseholds: () => { }, + createHousehold: () => { }, +}); + +export const HouseholdProvider = ({ children }) => { + const { token } = useContext(AuthContext); + const [households, setHouseholds] = useState([]); + const [activeHousehold, setActiveHouseholdState] = useState(null); + const [loading, setLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + + const clearActiveHousehold = useCallback(() => { + setActiveHouseholdState(null); + localStorage.removeItem(ACTIVE_HOUSEHOLD_STORAGE_KEY); + }, []); + + const loadHouseholds = useCallback(async () => { + if (!token) return; + + setLoading(true); + setError(null); + try { + const response = await getUserHouseholds(); + const nextHouseholds = Array.isArray(response.data) ? response.data : []; + setHouseholds(nextHouseholds); + + if (nextHouseholds.length === 0) { + clearActiveHousehold(); + } + } catch (err) { + console.error('[HouseholdContext] Failed to load households:', err); + setError(err.response?.data?.message || 'Failed to load households'); + setHouseholds([]); + clearActiveHousehold(); + } finally { + setLoading(false); + setHasLoaded(true); + } + }, [clearActiveHousehold, token]); + + // Load households on mount and when token changes + useEffect(() => { + if (token) { + setHasLoaded(false); + loadHouseholds(); + } else { + setHouseholds([]); + clearActiveHousehold(); + setError(null); + setLoading(false); + setHasLoaded(false); + } + }, [clearActiveHousehold, loadHouseholds, token]); + + // Load active household from localStorage on mount + useEffect(() => { + if (households.length === 0) { + setActiveHouseholdState(null); + return; + } + + const savedHouseholdId = localStorage.getItem(ACTIVE_HOUSEHOLD_STORAGE_KEY); + if (savedHouseholdId) { + const household = households.find((candidate) => String(candidate.id) === savedHouseholdId); + if (household) { + setActiveHouseholdState(household); + return; + } + } + + // No saved household or not found, use first one + setActiveHouseholdState(households[0]); + localStorage.setItem(ACTIVE_HOUSEHOLD_STORAGE_KEY, String(households[0].id)); + }, [households]); + + const setActiveHousehold = (household) => { + setActiveHouseholdState(household); + if (household) { + localStorage.setItem(ACTIVE_HOUSEHOLD_STORAGE_KEY, String(household.id)); + } else { + localStorage.removeItem(ACTIVE_HOUSEHOLD_STORAGE_KEY); + } + }; + + const createHousehold = async (name) => { + try { + const response = await createHouseholdApi(name); + const newHousehold = response.data.household; + + // Refresh households list + await loadHouseholds(); + + // Set new household as active + setActiveHousehold(newHousehold); + + return newHousehold; + } catch (err) { + console.error('Failed to create household:', err); + throw err; + } + }; + + const value = { + households, + activeHousehold, + loading, + hasLoaded, + error, + setActiveHousehold, + refreshHouseholds: loadHouseholds, + createHousehold, + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/context/StoreContext.jsx b/frontend/src/context/StoreContext.jsx new file mode 100644 index 0000000..5a97a03 --- /dev/null +++ b/frontend/src/context/StoreContext.jsx @@ -0,0 +1,99 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { getHouseholdStores } from '../api/stores'; +import { AuthContext } from './AuthContext'; +import { HouseholdContext } from './HouseholdContext'; + +export const StoreContext = createContext({ + stores: [], + activeStore: null, + loading: false, + error: null, + setActiveStore: () => { }, + refreshStores: () => { }, +}); + +export const StoreProvider = ({ children }) => { + const { token } = useContext(AuthContext); + const { activeHousehold } = useContext(HouseholdContext); + const [stores, setStores] = useState([]); + const [activeStore, setActiveStoreState] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Load stores when household changes + useEffect(() => { + if (token && activeHousehold) { + loadStores(); + } else { + // Clear state when logged out or no household + setStores([]); + setActiveStoreState(null); + } + }, [token, activeHousehold?.id]); + + // Load active store from localStorage on mount (per household) + useEffect(() => { + if (!activeHousehold || stores.length === 0) return; + + console.log('[StoreContext] Setting active store from:', stores); + const storageKey = `activeStoreId_${activeHousehold.id}`; + const savedStoreId = localStorage.getItem(storageKey); + + if (savedStoreId) { + const store = stores.find(s => s.id === parseInt(savedStoreId)); + if (store) { + console.log('[StoreContext] Found saved store:', store); + setActiveStoreState(store); + return; + } + } + + // No saved store or not found, use default or first one + const defaultStore = stores.find(s => s.is_default) || stores[0]; + console.log('[StoreContext] Using store:', defaultStore); + setActiveStoreState(defaultStore); + localStorage.setItem(storageKey, defaultStore.id); + }, [stores, activeHousehold]); + + const loadStores = async () => { + if (!token || !activeHousehold) return; + + setLoading(true); + setError(null); + try { + console.log('[StoreContext] Loading stores for household:', activeHousehold.id); + const response = await getHouseholdStores(activeHousehold.id); + console.log('[StoreContext] Loaded stores:', response.data); + setStores(response.data); + } catch (err) { + console.error('[StoreContext] Failed to load stores:', err); + setError(err.response?.data?.message || 'Failed to load stores'); + setStores([]); + } finally { + setLoading(false); + } + }; + + const setActiveStore = (store) => { + setActiveStoreState(store); + if (store && activeHousehold) { + const storageKey = `activeStoreId_${activeHousehold.id}`; + localStorage.setItem(storageKey, store.id); + } + }; + + const value = { + stores, + activeStore, + loading, + error, + setActiveStore, + refreshStores: loadStores, + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/context/UploadQueueContext.jsx b/frontend/src/context/UploadQueueContext.jsx new file mode 100644 index 0000000..d3d30be --- /dev/null +++ b/frontend/src/context/UploadQueueContext.jsx @@ -0,0 +1,406 @@ +import { createContext, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { updateItemImage } from "../api/list"; +import { + deleteUploadJob, + getAllUploadJobs, + saveUploadJob, +} from "../lib/uploadQueueStorage"; + +const SUCCESS_DISMISS_DELAY_MS = 2500; +const NETWORK_TIMEOUT_MS = 90000; +export const IMAGE_UPLOAD_SUCCESS_EVENT = "upload-queue:image-upload-success"; + +export const UploadQueueContext = createContext({ + uploads: [], + isOnline: true, + enqueueImageUpload: () => "", + retryUpload: () => {}, + discardUpload: () => {}, +}); + +function nowTs() { + return Date.now(); +} + +function createId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `upl_${nowTs()}_${Math.random().toString(16).slice(2)}`; +} + +function classifyUploadError(error, isOnline) { + if (!isOnline || error?.code === "ERR_NETWORK" || error?.code === "ECONNABORTED") { + return "Network issue. Check your connection and retry."; + } + + const responseMessage = + error?.response?.data?.error?.message || + error?.response?.data?.message || + error?.message; + + return responseMessage || "Upload failed. Retry or discard."; +} + +function matchesItemKey(a, b) { + return ( + a.householdId === b.householdId && + a.storeId === b.storeId && + String(a.itemName || "").toLowerCase() === String(b.itemName || "").toLowerCase() + ); +} + +export function UploadQueueProvider({ children }) { + const [uploads, setUploads] = useState([]); + const [isOnline, setIsOnline] = useState( + typeof navigator === "undefined" ? true : navigator.onLine + ); + + const uploadsRef = useRef([]); + const processingRef = useRef(false); + const controllerByIdRef = useRef(new Map()); + const successTimerByIdRef = useRef(new Map()); + + useEffect(() => { + uploadsRef.current = uploads; + }, [uploads]); + + const removeUpload = useCallback((uploadId) => { + const timer = successTimerByIdRef.current.get(uploadId); + if (timer) { + clearTimeout(timer); + successTimerByIdRef.current.delete(uploadId); + } + + const controller = controllerByIdRef.current.get(uploadId); + if (controller) { + controller.abort(); + controllerByIdRef.current.delete(uploadId); + } + + setUploads((prev) => prev.filter((upload) => upload.id !== uploadId)); + deleteUploadJob(uploadId).catch((error) => { + console.error("[UploadQueue] Failed to delete upload job:", error); + }); + }, []); + + const updateUpload = useCallback((uploadId, updater) => { + let updatedUpload = null; + + setUploads((prev) => + prev.map((upload) => { + if (upload.id !== uploadId) { + return upload; + } + + updatedUpload = { + ...updater(upload), + updatedAt: nowTs(), + }; + return updatedUpload; + }) + ); + + if (updatedUpload) { + saveUploadJob(updatedUpload).catch((error) => { + console.error("[UploadQueue] Failed to persist upload job:", error); + }); + } + + return updatedUpload; + }, []); + + useEffect(() => { + let isCancelled = false; + + const hydrateUploads = async () => { + try { + const stored = await getAllUploadJobs(); + if (isCancelled) return; + + const hydrated = []; + for (const upload of stored) { + if (upload.status === "discarded") { + deleteUploadJob(upload.id).catch(() => {}); + continue; + } + + if (upload.status === "uploading") { + const interrupted = { + ...upload, + status: "failed", + progress: 0, + lastError: "Upload interrupted. Retry or discard.", + updatedAt: nowTs(), + }; + hydrated.push(interrupted); + saveUploadJob(interrupted).catch((error) => { + console.error("[UploadQueue] Failed to persist interrupted upload:", error); + }); + continue; + } + + hydrated.push(upload); + } + + hydrated.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)); + setUploads(hydrated); + } catch (error) { + console.error("[UploadQueue] Failed to hydrate uploads:", error); + } + }; + + hydrateUploads(); + + return () => { + isCancelled = true; + }; + }, []); + + useEffect(() => { + const onOnline = () => setIsOnline(true); + const onOffline = () => setIsOnline(false); + + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + + return () => { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + }; + }, []); + + const processNextUpload = useCallback(async () => { + if (processingRef.current || !isOnline) { + return; + } + + const queuedUpload = uploadsRef.current.find((upload) => upload.status === "queued"); + if (!queuedUpload) { + return; + } + + processingRef.current = true; + const controller = new AbortController(); + controllerByIdRef.current.set(queuedUpload.id, controller); + + updateUpload(queuedUpload.id, (current) => ({ + ...current, + status: "uploading", + progress: 0, + lastError: null, + attemptCount: (current.attemptCount || 0) + 1, + })); + + try { + await updateItemImage( + queuedUpload.householdId, + queuedUpload.storeId, + queuedUpload.itemName, + queuedUpload.quantity, + queuedUpload.fileBlob, + { + signal: controller.signal, + timeoutMs: NETWORK_TIMEOUT_MS, + onUploadProgress: (event) => { + if (!event?.total || event.total <= 0) { + return; + } + const progress = Math.max( + 1, + Math.min(99, Math.round((event.loaded / event.total) * 100)) + ); + updateUpload(queuedUpload.id, (current) => ({ + ...current, + status: "uploading", + progress, + })); + }, + } + ); + + updateUpload(queuedUpload.id, (current) => ({ + ...current, + status: "success", + progress: 100, + lastError: null, + })); + + window.dispatchEvent( + new CustomEvent(IMAGE_UPLOAD_SUCCESS_EVENT, { + detail: { + uploadId: queuedUpload.id, + householdId: queuedUpload.householdId, + storeId: queuedUpload.storeId, + itemName: queuedUpload.itemName, + localItemId: queuedUpload.localItemId || null, + }, + }) + ); + } catch (error) { + if (error?.code !== "ERR_CANCELED") { + updateUpload(queuedUpload.id, (current) => ({ + ...current, + status: "failed", + progress: 0, + lastError: classifyUploadError(error, isOnline), + })); + } + } finally { + controllerByIdRef.current.delete(queuedUpload.id); + processingRef.current = false; + setTimeout(() => { + void processNextUpload(); + }, 0); + } + }, [isOnline, updateUpload]); + + useEffect(() => { + void processNextUpload(); + }, [uploads, isOnline, processNextUpload]); + + useEffect(() => { + const activeIds = new Set(uploads.map((upload) => upload.id)); + + for (const [id, timer] of successTimerByIdRef.current.entries()) { + if (!activeIds.has(id)) { + clearTimeout(timer); + successTimerByIdRef.current.delete(id); + } + } + + for (const upload of uploads) { + if (upload.status !== "success") { + continue; + } + if (successTimerByIdRef.current.has(upload.id)) { + continue; + } + + const timer = setTimeout(() => { + removeUpload(upload.id); + }, SUCCESS_DISMISS_DELAY_MS); + successTimerByIdRef.current.set(upload.id, timer); + } + }, [uploads, removeUpload]); + + useEffect(() => { + return () => { + for (const timer of successTimerByIdRef.current.values()) { + clearTimeout(timer); + } + successTimerByIdRef.current.clear(); + + for (const controller of controllerByIdRef.current.values()) { + controller.abort(); + } + controllerByIdRef.current.clear(); + }; + }, []); + + const enqueueImageUpload = useCallback( + ({ + householdId, + storeId, + itemName, + quantity, + fileBlob, + fileName, + fileType, + fileSize, + source, + localItemId = null, + }) => { + const upload = { + id: createId(), + kind: "item_image_upload", + status: "queued", + householdId, + storeId, + itemName, + quantity, + fileBlob, + fileName, + fileType, + fileSize, + source, + localItemId, + progress: 0, + attemptCount: 0, + lastError: null, + createdAt: nowTs(), + updatedAt: nowTs(), + }; + + const toRemove = []; + + setUploads((prev) => { + const next = []; + + for (const current of prev) { + const isDedupCandidate = + current.kind === "item_image_upload" && + ["queued", "uploading", "failed"].includes(current.status) && + matchesItemKey(current, upload); + + if (isDedupCandidate) { + toRemove.push(current.id); + continue; + } + + next.push(current); + } + + next.push(upload); + return next; + }); + + for (const uploadId of toRemove) { + const activeController = controllerByIdRef.current.get(uploadId); + if (activeController) { + activeController.abort(); + } + removeUpload(uploadId); + } + + saveUploadJob(upload).catch((error) => { + console.error("[UploadQueue] Failed to save queued upload:", error); + }); + + return upload.id; + }, + [removeUpload] + ); + + const retryUpload = useCallback( + (uploadId) => { + updateUpload(uploadId, (upload) => ({ + ...upload, + status: "queued", + progress: 0, + lastError: null, + })); + }, + [updateUpload] + ); + + const discardUpload = useCallback( + (uploadId) => { + removeUpload(uploadId); + }, + [removeUpload] + ); + + const value = useMemo( + () => ({ + uploads, + isOnline, + enqueueImageUpload, + retryUpload, + discardUpload, + }), + [uploads, isOnline, enqueueImageUpload, retryUpload, discardUpload] + ); + + return {children}; +} diff --git a/frontend/src/hooks/useActionToast.js b/frontend/src/hooks/useActionToast.js new file mode 100644 index 0000000..96227c4 --- /dev/null +++ b/frontend/src/hooks/useActionToast.js @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { ActionToastContext } from "../context/ActionToastContext"; + +export default function useActionToast() { + const context = useContext(ActionToastContext); + if (!context) { + throw new Error("useActionToast must be used within an ActionToastProvider"); + } + return context; +} diff --git a/frontend/src/hooks/useUploadQueue.js b/frontend/src/hooks/useUploadQueue.js new file mode 100644 index 0000000..1e6b145 --- /dev/null +++ b/frontend/src/hooks/useUploadQueue.js @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { UploadQueueContext } from "../context/UploadQueueContext"; + +export default function useUploadQueue() { + return useContext(UploadQueueContext); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index cf34cc8..a0e90fb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,73 +1,3 @@ -/* :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} */ - - /** * Global Base Styles * Uses theme variables defined in theme.css @@ -77,12 +7,20 @@ button:focus-visible { box-sizing: border-box; } +html { + min-width: 320px; + scroll-behavior: smooth; +} + body { font-family: var(--font-family-base); font-size: var(--font-size-base); line-height: var(--line-height-normal); color: var(--color-text-primary); - background: var(--color-bg-body); + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 34%), + radial-gradient(circle at top right, rgba(245, 158, 11, 0.14), transparent 28%), + linear-gradient(180deg, #faf8f3 0%, var(--color-bg-body) 42%, #efe8dc 100%); margin: 0; padding: 0; -webkit-font-smoothing: antialiased; @@ -90,44 +28,275 @@ body { transition: background-color 0.3s ease, color 0.3s ease; } +[data-theme="dark"] body, +body.dark-mode { + background: + radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 28%), + radial-gradient(circle at top right, rgba(251, 191, 36, 0.08), transparent 24%), + linear-gradient(180deg, #101823 0%, var(--color-bg-body) 44%, #0b1220 100%); +} + #root { min-height: 100vh; } -.container { - max-width: var(--container-max-width); - margin: auto; - padding: var(--container-padding); +a { + color: var(--color-primary); + text-decoration: none; + transition: color var(--transition-base), opacity var(--transition-base); } -h1 { - text-align: center; - font-size: 1.5em; +a:hover { + color: var(--color-primary-hover); +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + font-family: var(--font-family-heading); + line-height: var(--line-height-tight); + letter-spacing: -0.02em; +} + +p { + margin: 0; } input, -button, -select { - font-size: 1em; - margin: 0.3em 0; - padding: 0.5em; +select, +textarea { width: 100%; - box-sizing: border-box; + font: inherit; +} + +button { + font: inherit; } ul { list-style: none; padding: 0; + margin: 0; } -li { - padding: 0.5em; - background: #e9ecef; - margin-bottom: 0.5em; - border-radius: 4px; +.page-shell { + width: min(100%, var(--page-max-width)); + margin: 0 auto; + padding: clamp(1rem, 2vw, 1.75rem); +} + +.page-shell--narrow { + max-width: 560px; +} + +.page-shell--center { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.page-panel { + position: relative; + overflow: hidden; + width: 100%; + border: 1px solid var(--color-border-light); + border-radius: var(--border-radius-xl); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 255, 255, 0.88)), + var(--color-bg-surface); + box-shadow: var(--shadow-xl); + backdrop-filter: blur(18px); +} + +[data-theme="dark"] .page-panel, +body.dark-mode .page-panel { + background: + linear-gradient(180deg, rgba(20, 29, 42, 0.96), rgba(15, 23, 34, 0.9)), + var(--color-bg-surface); +} + +.page-panel::before { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 10px; + background: linear-gradient(90deg, var(--color-primary), var(--color-accent)); + opacity: 0.9; +} + +.page-panel-inner { + padding: clamp(1.25rem, 3vw, 2.4rem); +} + +.page-panel--compact .page-panel-inner { + padding: clamp(1.4rem, 4vw, 2.1rem); +} + +.page-hero { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin-bottom: 1.5rem; +} + +.page-eyebrow { + display: inline-flex; + align-items: center; + gap: 0.45rem; + width: fit-content; + padding: 0.45rem 0.8rem; + border-radius: var(--border-radius-full); + background: var(--color-primary-light); + color: var(--color-primary-dark); + font-size: 0.78rem; + font-weight: var(--font-weight-bold); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.page-title { + font-size: clamp(2rem, 4vw, 3.2rem); + color: var(--color-text-primary); +} + +.page-subtitle { + max-width: 44rem; + color: var(--color-text-secondary); + font-size: 1.02rem; + line-height: 1.7; +} + +.page-tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 0.6rem; + padding: 0.45rem; + border: 1px solid var(--color-border-light); + border-radius: var(--border-radius-full); + background: rgba(255, 255, 255, 0.62); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + +[data-theme="dark"] .page-tabs, +body.dark-mode .page-tabs { + background: rgba(15, 23, 34, 0.72); +} + +.page-tab { + min-width: 120px; + padding: 0.75rem 1.15rem; + border: none; + border-radius: var(--border-radius-full); + background: transparent; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); cursor: pointer; + transition: background var(--transition-base), color var(--transition-base), transform var(--transition-base); } -li:hover { - background: #dee2e6; +.page-tab:hover { + background: rgba(15, 118, 110, 0.08); + color: var(--color-text-primary); +} + +.page-tab.active { + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); + color: var(--color-text-inverse); + box-shadow: var(--shadow-sm); +} + +.surface-note { + border: 1px solid var(--color-border-light); + border-radius: var(--border-radius-lg); + background: rgba(255, 255, 255, 0.64); + padding: 1rem 1.1rem; + color: var(--color-text-secondary); +} + +[data-theme="dark"] .surface-note, +body.dark-mode .surface-note { + background: rgba(15, 23, 34, 0.72); +} + +.error-message, +.success-message { + margin-bottom: 1rem; + padding: 0.9rem 1rem; + border-radius: var(--border-radius-md); + border: 1px solid; + font-size: var(--font-size-sm); + line-height: 1.55; +} + +.error-message { + color: var(--color-danger); + background: var(--color-danger-light); + border-color: color-mix(in srgb, var(--color-danger) 35%, transparent); +} + +.success-message { + color: var(--color-success); + background: var(--color-success-light); + border-color: color-mix(in srgb, var(--color-success) 35%, transparent); +} + +.auth-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.auth-card { + width: min(100%, 30rem); +} + +.auth-card .page-hero { + margin-bottom: 1.25rem; +} + +.auth-subtitle { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: 1.7; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.auth-footer { + margin-top: 1.25rem; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + text-align: center; +} + +@media (max-width: 640px) { + .page-shell { + padding: 0.85rem; + } + + .page-tabs { + width: 100%; + } + + .page-tab { + flex: 1 1 calc(50% - 0.6rem); + min-width: 0; + } + + .page-title { + font-size: clamp(1.8rem, 8vw, 2.4rem); + } } diff --git a/frontend/src/lib/getApiErrorMessage.js b/frontend/src/lib/getApiErrorMessage.js new file mode 100644 index 0000000..02b463a --- /dev/null +++ b/frontend/src/lib/getApiErrorMessage.js @@ -0,0 +1,8 @@ +export default function getApiErrorMessage(error, fallbackMessage = "Unexpected error") { + return ( + error?.response?.data?.error?.message || + error?.response?.data?.message || + error?.message || + fallbackMessage + ); +} diff --git a/frontend/src/lib/uploadQueueStorage.js b/frontend/src/lib/uploadQueueStorage.js new file mode 100644 index 0000000..33de5ca --- /dev/null +++ b/frontend/src/lib/uploadQueueStorage.js @@ -0,0 +1,58 @@ +const DB_NAME = "costco-upload-queue"; +const DB_VERSION = 1; +const STORE_NAME = "uploads"; + +function openUploadQueueDb() { + return new Promise((resolve, reject) => { + if (typeof indexedDB === "undefined") { + reject(new Error("IndexedDB is not available")); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "id" }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("Failed to open upload DB")); + }); +} + +function withStore(mode, handler) { + return openUploadQueueDb().then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, mode); + const store = tx.objectStore(STORE_NAME); + const request = handler(store); + + tx.oncomplete = () => { + db.close(); + resolve(request?.result); + }; + tx.onerror = () => { + db.close(); + reject(tx.error || new Error("IndexedDB transaction failed")); + }; + }) + ); +} + +export function getAllUploadJobs() { + return withStore("readonly", (store) => store.getAll()).then((rows) => + Array.isArray(rows) ? rows : [] + ); +} + +export function saveUploadJob(job) { + return withStore("readwrite", (store) => store.put(job)); +} + +export function deleteUploadJob(id) { + return withStore("readwrite", (store) => store.delete(id)); +} diff --git a/frontend/src/pages/AdminPanel.jsx b/frontend/src/pages/AdminPanel.jsx index 2d26420..7f192c8 100644 --- a/frontend/src/pages/AdminPanel.jsx +++ b/frontend/src/pages/AdminPanel.jsx @@ -1,41 +1,85 @@ -import { useEffect, useState } from "react"; -import { getAllUsers, updateRole } from "../api/users"; -import UserRoleCard from "../components/common/UserRoleCard"; -import "../styles/UserRoleCard.css"; -import "../styles/pages/AdminPanel.css"; - -export default function AdminPanel() { - const [users, setUsers] = useState([]); - - async function loadUsers() { - const allUsers = await getAllUsers(); - setUsers(allUsers.data); - } +import { useEffect, useState } from "react"; +import { getAllUsers, updateRole } from "../api/users"; +import StoreManagement from "../components/admin/StoreManagement"; +import UserRoleCard from "../components/common/UserRoleCard"; +import useActionToast from "../hooks/useActionToast"; +import getApiErrorMessage from "../lib/getApiErrorMessage"; +import "../styles/UserRoleCard.css"; +import "../styles/pages/AdminPanel.css"; + +export default function AdminPanel() { + const toast = useActionToast(); + const [users, setUsers] = useState([]); + const [activeTab, setActiveTab] = useState("users"); + + async function loadUsers() { + try { + const allUsers = await getAllUsers(); + setUsers(allUsers.data); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to load users"); + toast.error("Load users failed", `Load users failed: ${message}`); + } + } useEffect(() => { loadUsers(); }, []); - const changeRole = async (id, role) => { - const updated = await updateRole(id, role); - if (updated.status !== 200) return; - loadUsers(); - } + const changeRole = async (id, role) => { + const selectedUser = users.find((user) => user.id === id); + const username = selectedUser?.username || `user #${id}`; + + try { + const updated = await updateRole(id, role); + if (updated.status !== 200) { + toast.error("Update role failed", "Update role failed: unexpected response"); + return; + } + toast.success("Updated user role", `Updated role for ${username} to ${role}`); + loadUsers(); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to update user role"); + toast.error("Update role failed", `Update role failed: ${message}`); + } + }; return ( -
-
-

Admin Panel

-
- {users.map((user) => ( - - ))} +
+
+

Admin Panel

+ +
+ + +
+ +
+ {activeTab === "users" && ( +
+ {users.map((user) => ( + + ))} +
+ )} + + {activeTab === "stores" && }
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index 293cd10..20cad76 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -1,69 +1,150 @@ import { useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { - addItem, - getClassification, - getItemByName, - getList, +import { useNavigate } from "react-router-dom"; +import { + addItem, + getClassification, + getItemByName, + getList, getRecentlyBought, - getSuggestions, - markBought, - updateItemImage, - updateItemWithClassification -} from "../api/list"; -import FloatingActionButton from "../components/common/FloatingActionButton"; -import SortDropdown from "../components/common/SortDropdown"; -import AddItemForm from "../components/forms/AddItemForm"; -import GroceryListItem from "../components/items/GroceryListItem"; -import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; -import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal"; -import EditItemModal from "../components/modals/EditItemModal"; -import SimilarItemModal from "../components/modals/SimilarItemModal"; -import { ZONE_FLOW } from "../constants/classifications"; -import { ROLES } from "../constants/roles"; -import { AuthContext } from "../context/AuthContext"; -import { SettingsContext } from "../context/SettingsContext"; -import "../styles/pages/GroceryList.css"; -import { findSimilarItems } from "../utils/stringSimilarity"; + getSuggestions, + markBought, + updateItemWithClassification +} from "../api/list"; +import { getHouseholdMembers } from "../api/households"; +import SortDropdown from "../components/common/SortDropdown"; +import AddItemForm from "../components/forms/AddItemForm"; +import NoHouseholdState from "../components/household/NoHouseholdState"; +import GroceryListItem from "../components/items/GroceryListItem"; +import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; +import ConfirmBuyModal from "../components/modals/ConfirmBuyModal"; +import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal"; +import EditItemModal from "../components/modals/EditItemModal"; +import SimilarItemModal from "../components/modals/SimilarItemModal"; +import StoreTabs from "../components/store/StoreTabs"; +import { ZONE_FLOW } from "../constants/classifications"; +import { AuthContext } from "../context/AuthContext"; +import { HouseholdContext } from "../context/HouseholdContext"; +import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext"; +import { SettingsContext } from "../context/SettingsContext"; +import { StoreContext } from "../context/StoreContext"; +import useActionToast from "../hooks/useActionToast"; +import useUploadQueue from "../hooks/useUploadQueue"; +import getApiErrorMessage from "../lib/getApiErrorMessage"; +import "../styles/pages/GroceryList.css"; +import { findSimilarItems } from "../utils/stringSimilarity"; + +function sortItemsForMode(items, sortMode) { + const sorted = [...items]; + + if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name)); + if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name)); + if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity); + if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity); + if (sortMode === "zone") { + sorted.sort((a, b) => { + if (!a.zone && b.zone) return 1; + if (a.zone && !b.zone) return -1; + if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name); + + const aZoneIndex = ZONE_FLOW.indexOf(a.zone); + const bZoneIndex = ZONE_FLOW.indexOf(b.zone); + const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex; + const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex; + + const zoneCompare = aIndex - bIndex; + if (zoneCompare !== 0) return zoneCompare; + + const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); + if (typeCompare !== 0) return typeCompare; + + const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); + if (groupCompare !== 0) return groupCompare; + + return a.item_name.localeCompare(b.item_name); + }); + } + + return sorted; +} + +function getNextModalItem(sortedItems, currentIndex, excludedItemId) { + const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId); + + if (remainingItems.length === 0) { + return null; + } + + return remainingItems[currentIndex] || remainingItems[0]; +} + + +export default function GroceryList() { + const pageTitle = "Grocery List"; + const { userId } = useContext(AuthContext); + const { + activeHousehold, + households, + loading: householdLoading, + hasLoaded: householdsLoaded + } = useContext(HouseholdContext); + const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); + const { settings } = useContext(SettingsContext); + const toast = useActionToast(); + const { enqueueImageUpload } = useUploadQueue(); + const navigate = useNavigate(); - -export default function GroceryList() { - const { role } = useContext(AuthContext); - const { settings } = useContext(SettingsContext); + // Get household role for permissions + const householdRole = activeHousehold?.role; + const isHouseholdAdmin = ["owner", "admin"].includes(householdRole); + const canEditList = Boolean(householdRole && householdRole !== "viewer"); // === State === // - const [items, setItems] = useState([]); - const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); - const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); - const [sortMode, setSortMode] = useState(settings.defaultSortMode); - const [suggestions, setSuggestions] = useState([]); - const [showAddForm, setShowAddForm] = useState(true); - const [loading, setLoading] = useState(true); + const [items, setItems] = useState([]); + const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); + const [householdMembers, setHouseholdMembers] = useState([]); + const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); + const [sortMode, setSortMode] = useState(settings.defaultSortMode); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(true); const [buttonText, setButtonText] = useState("Add Item"); const [pendingItem, setPendingItem] = useState(null); - const [showAddDetailsModal, setShowAddDetailsModal] = useState(false); - const [showSimilarModal, setShowSimilarModal] = useState(false); - const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null); - const [showEditModal, setShowEditModal] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed); - const [collapsedZones, setCollapsedZones] = useState({}); - const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false); - const [confirmAddExistingData, setConfirmAddExistingData] = useState(null); + const [showAddDetailsModal, setShowAddDetailsModal] = useState(false); + const [showSimilarModal, setShowSimilarModal] = useState(false); + const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null); + const [showEditModal, setShowEditModal] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed); + const [collapsedZones, setCollapsedZones] = useState({}); + const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false); + const [confirmAddExistingData, setConfirmAddExistingData] = useState(null); + const [buyModalState, setBuyModalState] = useState(null); // === Data Loading === const loadItems = async () => { + if (!activeHousehold?.id || !activeStore?.id) { + setLoading(false); + return; + } + setLoading(true); - const res = await getList(); - console.log(res.data); - setItems(res.data); - setLoading(false); + try { + const res = await getList(activeHousehold.id, activeStore.id); + console.log('[GroceryList] Items loaded:', res.data); + setItems(res.data.items || res.data || []); + } catch (error) { + console.error('[GroceryList] Failed to load items:', error); + setItems([]); + } finally { + setLoading(false); + } }; const loadRecentlyBought = async () => { + if (!activeHousehold?.id || !activeStore?.id) return; try { - const res = await getRecentlyBought(); + const res = await getRecentlyBought(activeHousehold.id, activeStore.id); setRecentlyBoughtItems(res.data); } catch (error) { console.error("Failed to load recently bought items:", error); @@ -72,10 +153,81 @@ export default function GroceryList() { }; - useEffect(() => { - loadItems(); - loadRecentlyBought(); - }, []); + useEffect(() => { + loadItems(); + loadRecentlyBought(); + }, [activeHousehold?.id, activeStore?.id]); + + useEffect(() => { + setBuyModalState(null); + }, [activeHousehold?.id, activeStore?.id]); + + useEffect(() => { + const loadHouseholdMembers = async () => { + if (!activeHousehold?.id) { + setHouseholdMembers([]); + return; + } + + try { + const response = await getHouseholdMembers(activeHousehold.id); + setHouseholdMembers(response.data || []); + } catch (error) { + console.error("Failed to load household members:", error); + setHouseholdMembers([]); + } + }; + + loadHouseholdMembers(); + }, [activeHousehold?.id]); + + useEffect(() => { + const handleUploadSuccess = async (event) => { + const detail = event?.detail || {}; + if (!activeHousehold?.id || !activeStore?.id) return; + if (String(detail.householdId) !== String(activeHousehold.id)) return; + if (String(detail.storeId) !== String(activeStore.id)) return; + if (!detail.itemName) return; + + try { + const response = await getItemByName(activeHousehold.id, activeStore.id, detail.itemName); + const refreshedItem = response.data; + + setItems((prev) => + prev.map((item) => { + const byId = + detail.localItemId !== null && + detail.localItemId !== undefined && + item.id === detail.localItemId; + const byName = + String(item.item_name || "").toLowerCase() === + String(detail.itemName || "").toLowerCase(); + return byId || byName ? { ...item, ...refreshedItem } : item; + }) + ); + + setRecentlyBoughtItems((prev) => + prev.map((item) => { + const byId = + detail.localItemId !== null && + detail.localItemId !== undefined && + item.id === detail.localItemId; + const byName = + String(item.item_name || "").toLowerCase() === + String(detail.itemName || "").toLowerCase(); + return byId || byName ? { ...item, ...refreshedItem } : item; + }) + ); + } catch (error) { + console.error("Failed to refresh item after upload success:", error); + } + }; + + window.addEventListener(IMAGE_UPLOAD_SUCCESS_EVENT, handleUploadSuccess); + return () => { + window.removeEventListener(IMAGE_UPLOAD_SUCCESS_EVENT, handleUploadSuccess); + }; + }, [activeHousehold?.id, activeStore?.id]); // === Zone Collapse Handler === @@ -87,46 +239,37 @@ export default function GroceryList() { }; // === Sorted Items Computation === - const sortedItems = useMemo(() => { - const sorted = [...items]; - - if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name)); - if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name)); - if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity); - if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity); - if (sortMode === "zone") { - sorted.sort((a, b) => { - // Items without classification go to the end - if (!a.zone && b.zone) return 1; - if (a.zone && !b.zone) return -1; - if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name); - - // Sort by ZONE_FLOW order - const aZoneIndex = ZONE_FLOW.indexOf(a.zone); - const bZoneIndex = ZONE_FLOW.indexOf(b.zone); - - // If zone not in ZONE_FLOW, put at end - const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex; - const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex; - - const zoneCompare = aIndex - bIndex; - if (zoneCompare !== 0) return zoneCompare; - - // Then by item_type - const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); - if (typeCompare !== 0) return typeCompare; - - // Then by item_group - const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); - if (groupCompare !== 0) return groupCompare; - - // Finally by name - return a.item_name.localeCompare(b.item_name); - }); - } - - return sorted; - }, [items, sortMode]); + const sortedItems = useMemo(() => { + return sortItemsForMode(items, sortMode); + }, [items, sortMode]); + + const visibleRecentlyBoughtItems = useMemo( + () => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount), + [recentlyBoughtItems, recentlyBoughtDisplayCount] + ); + + const buyModalItems = useMemo(() => { + if (!buyModalState) return []; + + return buyModalState.source === "active" + ? sortedItems + : visibleRecentlyBoughtItems; + }, [buyModalState, sortedItems, visibleRecentlyBoughtItems]); + + useEffect(() => { + if (!buyModalState) return; + + const refreshedItem = buyModalItems.find((item) => item.id === buyModalState.item.id); + if (!refreshedItem || refreshedItem === buyModalState.item) return; + + setBuyModalState((prev) => { + if (!prev || prev.item.id !== refreshedItem.id || prev.source !== buyModalState.source) { + return prev; + } + + return { ...prev, item: refreshedItem }; + }); + }, [buyModalItems, buyModalState]); // === Suggestion Handler === @@ -137,10 +280,16 @@ export default function GroceryList() { return; } + if (!activeHousehold?.id || !activeStore?.id) { + setSuggestions([]); + setButtonText("Create + Add"); + return; + } + const lowerText = text.toLowerCase().trim(); try { - const response = await getSuggestions(text); + const response = await getSuggestions(activeHousehold.id, activeStore.id, text); const suggestionList = response.data.map(s => s.item_name); setSuggestions(suggestionList); @@ -155,72 +304,108 @@ export default function GroceryList() { // === Item Addition Handlers === - const handleAdd = useCallback(async (itemName, quantity) => { - if (!itemName.trim()) return; - - let existingItem = null; - try { - const response = await getItemByName(itemName); - existingItem = response.data; - } catch { - existingItem = null; - } - - if (existingItem) { - await processItemAddition(itemName, quantity); - return; - } - - setItems(prevItems => { - const allItems = [...prevItems, ...recentlyBoughtItems]; - const similar = findSimilarItems(itemName, allItems, 70); - if (similar.length > 0) { - setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity }); - setShowSimilarModal(true); - return prevItems; - } - - processItemAddition(itemName, quantity); - return prevItems; - }); - }, [recentlyBoughtItems]); - - - const processItemAddition = useCallback(async (itemName, quantity) => { - let existingItem = null; - try { - const response = await getItemByName(itemName); - existingItem = response.data; - } catch { - existingItem = null; - } - - if (existingItem?.bought === false) { + const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => { + try { + const normalizedItemName = itemName.trim().toLowerCase(); + if (!normalizedItemName) return; + if (!activeHousehold?.id || !activeStore?.id) return; + + const allItems = [...items, ...recentlyBoughtItems]; + const existingLocalItem = allItems.find( + (item) => String(item.item_name || "").toLowerCase() === normalizedItemName + ); + + if (existingLocalItem) { + await processItemAddition(itemName, quantity, { + existingItem: existingLocalItem, + addedForUserId + }); + return; + } + + const similar = findSimilarItems(itemName, allItems, 70); + if (similar.length > 0) { + setSimilarItemSuggestion({ + originalName: itemName, + suggestedItem: similar[0], + quantity, + addedForUserId + }); + setShowSimilarModal(true); + return; + } + + const shouldSkipLookup = buttonText === "Create + Add"; + await processItemAddition(itemName, quantity, { + skipLookup: shouldSkipLookup, + addedForUserId + }); + } catch (error) { + console.error("Failed to process add item flow:", error); + } + }, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]); + + + const processItemAddition = useCallback(async (itemName, quantity, options = {}) => { + if (!activeHousehold?.id || !activeStore?.id) return; + const { + existingItem: providedItem = null, + skipLookup = false, + addedForUserId = null + } = options; + + let existingItem = providedItem; + if (!existingItem && !skipLookup) { + try { + const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); + existingItem = response.data; + } catch { + // Item doesn't exist, continue with add + } + } + + if (existingItem?.bought === false) { const currentQuantity = existingItem.quantity; const newQuantity = currentQuantity + quantity; // Show modal instead of window.confirm - setConfirmAddExistingData({ - itemName, - currentQuantity, - addingQuantity: quantity, - newQuantity, - existingItem - }); - setShowConfirmAddExisting(true); - } else if (existingItem) { - await addItem(itemName, quantity, null); - setSuggestions([]); - setButtonText("Add Item"); - - // Reload lists to reflect the changes - await loadItems(); - await loadRecentlyBought(); - } else { - setPendingItem({ itemName, quantity }); - setShowAddDetailsModal(true); - } - }, []); + setConfirmAddExistingData({ + itemName, + currentQuantity, + addingQuantity: quantity, + newQuantity, + existingItem, + addedForUserId + }); + setShowConfirmAddExisting(true); + } else if (existingItem) { + try { + await addItem( + activeHousehold.id, + activeStore.id, + itemName, + quantity, + null, + null, + addedForUserId + ); + setSuggestions([]); + setButtonText("Add Item"); + toast.success("Added item", `Added item ${itemName}`); + + // Reload lists to reflect the changes + await loadItems(); + await loadRecentlyBought(); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to add item"); + toast.error("Add item failed", `Add item failed: ${message}`); + throw error; + } + } else { + setPendingItem({ itemName, quantity, addedForUserId }); + setShowAddDetailsModal(true); + } + }, [activeHousehold?.id, activeStore?.id, loadItems, loadRecentlyBought, toast]); // === Similar Item Modal Handlers === @@ -230,110 +415,163 @@ export default function GroceryList() { }, []); - const handleSimilarNo = useCallback(async () => { - if (!similarItemSuggestion) return; - setShowSimilarModal(false); - await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity); - setSimilarItemSuggestion(null); - }, [similarItemSuggestion, processItemAddition]); + const handleSimilarNo = useCallback(async () => { + if (!similarItemSuggestion) return; + setShowSimilarModal(false); + await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity, { + skipLookup: true, + addedForUserId: similarItemSuggestion.addedForUserId || null + }); + setSimilarItemSuggestion(null); + }, [similarItemSuggestion, processItemAddition]); - const handleSimilarYes = useCallback(async () => { - if (!similarItemSuggestion) return; - setShowSimilarModal(false); - await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity); - setSimilarItemSuggestion(null); - }, [similarItemSuggestion, processItemAddition]); + const handleSimilarYes = useCallback(async () => { + if (!similarItemSuggestion) return; + setShowSimilarModal(false); + await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity, { + addedForUserId: similarItemSuggestion.addedForUserId || null + }); + setSimilarItemSuggestion(null); + }, [similarItemSuggestion, processItemAddition]); // === Confirm Add Existing Modal Handlers === const handleConfirmAddExisting = useCallback(async () => { if (!confirmAddExistingData) return; + if (!activeHousehold?.id || !activeStore?.id) return; - const { itemName, newQuantity, existingItem } = confirmAddExistingData; + const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData; setShowConfirmAddExisting(false); setConfirmAddExistingData(null); try { - // Update the item - await addItem(itemName, newQuantity, null); + await addItem( + activeHousehold.id, + activeStore.id, + itemName, + newQuantity, + null, + null, + addedForUserId || null + ); - // Fetch the updated item with properly formatted data - const response = await getItemByName(itemName); + const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); const updatedItem = response.data; - // Update state with the full item data - setItems(prevItems => - prevItems.map(item => - item.id === existingItem.id ? updatedItem : item - ) - ); - - setSuggestions([]); - setButtonText("Add Item"); - } catch (error) { - console.error("Failed to update item:", error); - // Fallback to full reload on error - await loadItems(); - } - }, [confirmAddExistingData, loadItems]); - - const handleCancelAddExisting = useCallback(() => { - setShowConfirmAddExisting(false); - setConfirmAddExistingData(null); - }, []); + setItems(prevItems => + prevItems.map(item => + item.id === existingItem.id ? updatedItem : item + ) + ); + + setSuggestions([]); + setButtonText("Add Item"); + toast.success("Updated item quantity", `Updated item ${itemName}`); + } catch (error) { + console.error("Failed to update item:", error); + const message = getApiErrorMessage(error, "Failed to update item"); + toast.error("Update item failed", `Update item failed: ${message}`); + await loadItems(); + } + }, [activeHousehold?.id, activeStore?.id, confirmAddExistingData, loadItems, toast]); // === Add Details Modal Handlers === - const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => { - if (!pendingItem) return; + const handleAddWithDetails = useCallback(async (imageFile, classification) => { + if (!pendingItem) return; + if (!activeHousehold?.id || !activeStore?.id) return; + + try { + // Create the list item first, upload image separately in background. + await addItem( + activeHousehold.id, + activeStore.id, + pendingItem.itemName, + pendingItem.quantity, + null, + null, + pendingItem.addedForUserId || null + ); + + if (classification) { + // Apply classification if provided + await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification); + toast.success("Updated item classification", `Updated classification for ${pendingItem.itemName}`); + } - try { - const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); - let newItem = addResponse.data; - - if (classification) { - const itemResponse = await getItemByName(pendingItem.itemName); - const itemId = itemResponse.data.id; - const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification); - newItem = { ...newItem, ...updateResponse.data }; - } + // Fetch the newly added item + const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); + const newItem = itemResponse.data; setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); - if (newItem) { - setItems(prevItems => [...prevItems, newItem]); - } - } catch (error) { - console.error("Failed to add item:", error); - alert("Failed to add item. Please try again."); - } - }, [pendingItem]); - + // Add to state + if (newItem) { + setItems(prevItems => [...prevItems, newItem]); + toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`); + + if (imageFile) { + enqueueImageUpload({ + householdId: activeHousehold.id, + storeId: activeStore.id, + itemName: newItem.item_name || pendingItem.itemName, + quantity: newItem.quantity || pendingItem.quantity, + fileBlob: imageFile, + fileName: imageFile.name || "upload.jpg", + fileType: imageFile.type || "image/jpeg", + fileSize: imageFile.size || 0, + source: "add_details", + localItemId: newItem.id, + }); + toast.info("Queued image upload", `Queued image upload for ${newItem.item_name || pendingItem.itemName}`); + } + } + } catch (error) { + console.error("Failed to add item:", error); + const message = getApiErrorMessage(error, "Failed to add item"); + toast.error("Add item failed", `Add item failed: ${message}`); + } + }, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]); const handleAddDetailsSkip = useCallback(async () => { if (!pendingItem) return; + if (!activeHousehold?.id || !activeStore?.id) return; try { - const response = await addItem(pendingItem.itemName, pendingItem.quantity, null); + await addItem( + activeHousehold.id, + activeStore.id, + pendingItem.itemName, + pendingItem.quantity, + null, + null, + pendingItem.addedForUserId || null + ); + + // Fetch the newly added item + const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); + const newItem = itemResponse.data; setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); - setButtonText("Add Item"); - - if (response.data) { - setItems(prevItems => [...prevItems, response.data]); - } - } catch (error) { - console.error("Failed to add item:", error); - alert("Failed to add item. Please try again."); - } - }, [pendingItem]); + setButtonText("Add Item"); + + if (newItem) { + setItems(prevItems => [...prevItems, newItem]); + toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`); + } + } catch (error) { + console.error("Failed to add item:", error); + const message = getApiErrorMessage(error, "Failed to add item"); + toast.error("Add item failed", `Add item failed: ${message}`); + } + }, [activeHousehold?.id, activeStore?.id, pendingItem, toast]); const handleAddDetailsCancel = useCallback(() => { @@ -345,93 +583,169 @@ export default function GroceryList() { // === Item Action Handlers === - const handleBought = useCallback(async (id, quantity) => { - const item = items.find(i => i.id === id); - if (!item) return; + const handleBought = useCallback(async (quantity) => { + if (!activeHousehold?.id || !activeStore?.id) return; + if (!buyModalState || buyModalState.source !== "active") { + setBuyModalState(null); + return; + } + + const item = items.find((listItem) => listItem.id === buyModalState.item.id) || buyModalState.item; + if (!item) return; + + try { + const currentIndex = sortedItems.findIndex((listItem) => listItem.id === item.id); + const resolvedIndex = currentIndex >= 0 ? currentIndex : 0; + + await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true); + + let nextItems = items; + + if (quantity >= item.quantity) { + nextItems = items.filter((existingItem) => existingItem.id !== item.id); + } else { + const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name); + const updatedItem = response.data; + + nextItems = items.map((existingItem) => + existingItem.id === item.id ? updatedItem : existingItem + ); + } + + setItems(nextItems); + + const nextSortedItems = sortItemsForMode(nextItems, sortMode); + const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id); + + setBuyModalState( + nextModalItem + ? { + item: nextModalItem, + source: "active", + canConfirm: true, + } + : null + ); + + toast.success("Marked item bought", `Marked item ${item.item_name} as bought`); + loadRecentlyBought(); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to mark item as bought"); + toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); + } + }, [activeHousehold?.id, activeStore?.id, buyModalState, items, sortMode, sortedItems, toast]); + + const openActiveBuyModal = useCallback((item) => { + setBuyModalState({ + item, + source: "active", + canConfirm: canEditList, + }); + }, [canEditList]); + + const openRecentBuyModal = useCallback((item) => { + setBuyModalState({ + item, + source: "recent", + canConfirm: false, + }); + }, []); + + const handleBuyModalCancel = useCallback(() => { + setBuyModalState(null); + }, []); + + const handleBuyModalNavigate = useCallback((item) => { + setBuyModalState((prev) => (prev ? { ...prev, item } : prev)); + }, []); + + const handleBuyModalConfirm = useCallback(async (quantity) => { + if (!buyModalState?.canConfirm) { + setBuyModalState(null); + return; + } + + await handleBought(quantity); + }, [buyModalState?.canConfirm, handleBought]); - await markBought(id, quantity); - - // If buying full quantity, remove from list - if (quantity >= item.quantity) { - setItems(prevItems => prevItems.filter(item => item.id !== id)); - } else { - // If partial, update quantity - const response = await getItemByName(item.item_name); - if (response.data) { - setItems(prevItems => - prevItems.map(item => item.id === id ? response.data : item) - ); - } - } - - loadRecentlyBought(); - }, [items]); + const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => { + if (!activeHousehold?.id || !activeStore?.id) return; + if (!imageFile) return; + + try { + enqueueImageUpload({ + householdId: activeHousehold.id, + storeId: activeStore.id, + itemName, + quantity, + fileBlob: imageFile, + fileName: imageFile.name || "upload.jpg", + fileType: imageFile.type || "image/jpeg", + fileSize: imageFile.size || 0, + source, + localItemId: id, + }); + toast.info("Queued image upload", `Queued image upload for ${itemName}`); + } catch (error) { + console.error("Failed to add image:", error); + const message = getApiErrorMessage(error, "Failed to add image"); + toast.error("Add image failed", `Add image failed: ${message}`); + } + }, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]); - const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => { - try { - const response = await updateItemImage(id, itemName, quantity, imageFile); - - setItems(prevItems => - prevItems.map(item => - item.id === id ? { ...item, ...response.data } : item - ) - ); - - setRecentlyBoughtItems(prevItems => - prevItems.map(item => - item.id === id ? { ...item, ...response.data } : item - ) - ); - } catch (error) { - console.error("Failed to add image:", error); - alert("Failed to add image. Please try again."); - } - }, []); - - - const handleLongPress = useCallback(async (item) => { - if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return; - - try { - const classificationResponse = await getClassification(item.id); - setEditingItem({ - ...item, - classification: classificationResponse.data - }); - setShowEditModal(true); - } catch (error) { - console.error("Failed to load classification:", error); + const handleLongPress = useCallback(async (item) => { + if (!householdRole || householdRole === 'viewer') return; + if (!activeHousehold?.id || !activeStore?.id) return; + + try { + const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.item_name); + setEditingItem({ + ...item, + classification: classificationResponse.data?.classification || null + }); + setShowEditModal(true); + } catch (error) { + console.error("Failed to load classification:", error); setEditingItem({ ...item, classification: null }); setShowEditModal(true); } - }, [role]); + }, [activeHousehold?.id, activeStore?.id, householdRole]); // === Edit Modal Handlers === const handleEditSave = useCallback(async (id, itemName, quantity, classification) => { + if (!activeHousehold?.id || !activeStore?.id) return; + try { - const response = await updateItemWithClassification(id, itemName, quantity, classification); + await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification); + + // Fetch the updated item + const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); + const updatedItem = response.data; + setShowEditModal(false); setEditingItem(null); - const updatedItem = response.data; setItems(prevItems => prevItems.map(item => - item.id === id ? { ...item, ...updatedItem } : item + item.id === id ? updatedItem : item ) ); - setRecentlyBoughtItems(prevItems => - prevItems.map(item => - item.id === id ? { ...item, ...updatedItem } : item - ) - ); - } catch (error) { - console.error("Failed to update item:", error); - throw error; - } - }, []); + setRecentlyBoughtItems(prevItems => + prevItems.map(item => + item.id === id ? { ...item, ...updatedItem } : item + ) + ); + toast.success("Updated item", `Updated item ${itemName}`); + } catch (error) { + console.error("Failed to update item:", error); + const message = getApiErrorMessage(error, "Failed to update item"); + toast.error("Update item failed", `Update item failed: ${message}`); + throw error; + } + }, [activeHousehold?.id, activeStore?.id, toast]); const handleEditCancel = useCallback(() => { @@ -454,23 +768,115 @@ export default function GroceryList() { }; - if (loading) return

Loading...

; + if (!householdsLoaded || householdLoading || (households.length > 0 && !activeHousehold)) { + return ( +
+
+

{pageTitle}

+

+ Loading households... +

+
+
+ ); + } + + if (!activeHousehold) { + return ( +
+
+

{pageTitle}

+ +
+
+ ); + } + + if (storeLoading) { + return ( +
+
+

{pageTitle}

+

+ Loading stores... +

+
+
+ ); + } + + if (!storeLoading && stores.length === 0) { + return ( +
+
+

{pageTitle}

+
+

No stores found

+

+ This household doesn’t have any stores yet. +

+ {isHouseholdAdmin ? ( + + ) : ( +

+ Please notify a household admin to add a store. +

+ )} +
+
+
+ ); + } + + if (!activeStore) { + return ( +
+
+

{pageTitle}

+ +

+ Loading stores... +

+
+
+ ); + } + + if (loading) { + return ( +
+
+

{pageTitle}

+ +

Loading grocery list...

+
+
+ ); + } return (
-

Costco Grocery List

+

{pageTitle}

+ - {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && ( - - )} + {canEditList && ( + + )} @@ -497,21 +903,19 @@ export default function GroceryList() { {!isCollapsed && (
    {grouped[zone].map((item) => ( - - [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity) - } - onImageAdded={ - [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null - } - onLongPress={ - [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null - } - /> + ))}
)} @@ -522,21 +926,19 @@ export default function GroceryList() { ) : (
    {sortedItems.map((item) => ( - - [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity) - } - onImageAdded={ - [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null - } - onLongPress={ - [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null - } - /> + ))}
)} @@ -556,21 +958,21 @@ export default function GroceryList() { {!recentlyBoughtCollapsed && ( <>
    - {recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => ( - - ))} + {visibleRecentlyBoughtItems.map((item) => ( + + ))}
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
@@ -588,17 +990,10 @@ export default function GroceryList() { )}
- {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && ( - setShowAddForm(!showAddForm)} - /> - )} - - {showAddDetailsModal && pendingItem && ( - @@ -614,24 +1009,37 @@ export default function GroceryList() { /> )} - {showEditModal && editingItem && ( - - )} - - {showConfirmAddExisting && confirmAddExistingData && ( - + )} + + {buyModalState && ( + + )} + + {showConfirmAddExisting && confirmAddExistingData && ( + - )} -
- ); -} + onCancel={() => { + setShowConfirmAddExisting(false); + setConfirmAddExistingData(null); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/InviteLink.jsx b/frontend/src/pages/InviteLink.jsx new file mode 100644 index 0000000..c5b3938 --- /dev/null +++ b/frontend/src/pages/InviteLink.jsx @@ -0,0 +1,158 @@ +import { useContext, useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { acceptInviteLink, getInviteLinkSummary } from "../api/households"; +import { AuthContext } from "../context/AuthContext"; +import { HouseholdContext } from "../context/HouseholdContext"; +import useActionToast from "../hooks/useActionToast"; +import getApiErrorMessage from "../lib/getApiErrorMessage"; +import "../styles/pages/InviteLink.css"; + +function humanizeStatus(code) { + switch (code) { + case "ALREADY_MEMBER": + return "You are already a member of this group."; + case "PENDING": + return "Your join request is already pending approval."; + case "REVOKED": + return "This invite link has been revoked."; + case "EXPIRED": + return "This invite link has expired."; + case "USED": + return "This invite link has already been used."; + case "NOT_ACCEPTING": + return "This group is not accepting new members right now."; + default: + return ""; + } +} + +export default function InviteLink() { + const { token } = useParams(); + const navigate = useNavigate(); + const { token: authToken } = useContext(AuthContext); + const { refreshHouseholds } = useContext(HouseholdContext); + const toast = useActionToast(); + const [loading, setLoading] = useState(true); + const [joining, setJoining] = useState(false); + const [link, setLink] = useState(null); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + + useEffect(() => { + const loadSummary = async () => { + if (!token) return; + setLoading(true); + setError(""); + setMessage(""); + + try { + const response = await getInviteLinkSummary(token); + setLink(response.data.link); + } catch (err) { + setError(err.response?.data?.error?.message || "Invite link not found"); + } finally { + setLoading(false); + } + }; + + loadSummary(); + }, [token]); + + const blockedCode = useMemo(() => { + if (!link) return null; + if (link.viewerStatus === "ALREADY_MEMBER") return "ALREADY_MEMBER"; + if (link.viewerStatus === "PENDING") return "PENDING"; + if (link.status === "REVOKED") return "REVOKED"; + if (link.status === "EXPIRED") return "EXPIRED"; + if (link.status === "USED") return "USED"; + if (link.active_policy === "NOT_ACCEPTING") return "NOT_ACCEPTING"; + return null; + }, [link]); + + const handleJoin = async () => { + if (!token) return; + setJoining(true); + setError(""); + setMessage(""); + + try { + const response = await acceptInviteLink(token); + const result = response.data.result; + if (result.status === "JOINED") { + setMessage(`Joined ${result.group.name}. Redirecting...`); + toast.success("Joined group", `Joined group ${result.group.name}`); + } else if (result.status === "PENDING") { + setMessage(`Request sent to join ${result.group.name}. Redirecting...`); + toast.info("Join request sent", `Request sent for ${result.group.name}`); + } else { + setMessage(`You are already a member of ${result.group.name}. Redirecting...`); + toast.info("Already a member", `Already a member of ${result.group.name}`); + } + + await refreshHouseholds(); + window.setTimeout(() => navigate("/"), 1200); + } catch (err) { + const message = getApiErrorMessage(err, "Failed to process invite"); + setError(message); + toast.error("Join invite failed", `Join invite failed: ${message}`); + } finally { + setJoining(false); + } + }; + + if (loading) { + return ( +
+
+

Invite Link

+

Loading invite details...

+
+
+ ); + } + + if (error || !link) { + return ( +
+
+

Invite Link

+

{error || "Invite link not found"}

+
+
+ ); + } + + return ( +
+
+

Join {link.group_name}

+

Invite status: {link.status}

+ + {message &&

{message}

} + {!message && blockedCode &&

{humanizeStatus(blockedCode)}

} + {!message && !blockedCode && !authToken && ( +

Sign in or register to join this group.

+ )} + + {!message && !authToken && ( +
+ + Sign In + + + Register + +
+ )} + + {!message && authToken && !blockedCode && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 796a6d9..43d58c9 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,30 +1,37 @@ -import { useContext, useState } from "react"; -import { Link } from "react-router-dom"; -import { loginRequest } from "../api/auth"; -import ErrorMessage from "../components/common/ErrorMessage"; -import FormInput from "../components/common/FormInput"; -import { AuthContext } from "../context/AuthContext"; -import "../styles/pages/Login.css"; - -export default function Login() { - const { login } = useContext(AuthContext); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); +import { useContext, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { loginRequest } from "../api/auth"; +import ErrorMessage from "../components/common/ErrorMessage"; +import FormInput from "../components/common/FormInput"; +import { AuthContext } from "../context/AuthContext"; +import useActionToast from "../hooks/useActionToast"; +import getApiErrorMessage from "../lib/getApiErrorMessage"; +import "../styles/pages/Login.css"; + +export default function Login() { + const navigate = useNavigate(); + const { login } = useContext(AuthContext); + const toast = useActionToast(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); const submit = async (e) => { e.preventDefault(); setError(""); - try { - const data = await loginRequest(username, password); - login(data); - window.location.href = "/"; - } catch (err) { - setError(err.response?.data?.message || "Login failed"); - } - }; + try { + const data = await loginRequest(username, password); + login(data); + toast.success("Logged in", `Welcome back ${data?.username || username}`); + navigate("/"); + } catch (err) { + const message = getApiErrorMessage(err, "Login failed"); + setError(message); + toast.error("Login failed", `Login failed: ${message}`); + } + }; return (
diff --git a/frontend/src/pages/Manage.jsx b/frontend/src/pages/Manage.jsx new file mode 100644 index 0000000..4bd6837 --- /dev/null +++ b/frontend/src/pages/Manage.jsx @@ -0,0 +1,72 @@ +import { useContext, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import NoHouseholdState from "../components/household/NoHouseholdState"; +import ManageHousehold from "../components/manage/ManageHousehold"; +import ManageStores from "../components/manage/ManageStores"; +import { HouseholdContext } from "../context/HouseholdContext"; +import "../styles/pages/Manage.css"; + +export default function Manage() { + const { activeHousehold, households, loading, hasLoaded } = useContext(HouseholdContext); + const [activeTab, setActiveTab] = useState("household"); + const [searchParams] = useSearchParams(); + + useEffect(() => { + const tab = searchParams.get("tab"); + if (tab === "household" || tab === "stores") { + setActiveTab(tab); + } + }, [searchParams]); + + if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { + return ( +
+
+

Manage

+

+ Loading household... +

+
+
+ ); + } + + if (!activeHousehold) { + return ( +
+
+

Manage

+ +
+
+ ); + } + + return ( +
+
+

Manage {activeHousehold.name}

+ +
+ + +
+ +
+ {activeTab === "household" && } + {activeTab === "stores" && } +
+
+
+ ); +} diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index ed2a9ef..cea9059 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -5,11 +5,14 @@ import { checkIfUserExists } from "../api/users"; import ErrorMessage from "../components/common/ErrorMessage"; import FormInput from "../components/common/FormInput"; import { AuthContext } from "../context/AuthContext"; +import useActionToast from "../hooks/useActionToast"; +import getApiErrorMessage from "../lib/getApiErrorMessage"; import "../styles/pages/Register.css"; export default function Register() { const navigate = useNavigate(); const { login } = useContext(AuthContext); + const toast = useActionToast(); const [name, setName] = useState(""); const [username, setUsername] = useState(""); @@ -47,12 +50,16 @@ export default function Register() { try { await registerRequest(username, password, name); + toast.success("Registered account", `Created account for ${username}`); const data = await loginRequest(username, password); login(data); + toast.success("Logged in", `Welcome ${data?.username || username}`); setSuccess("Account created! Redirecting to the grocery list..."); setTimeout(() => navigate("/"), 2000); } catch (err) { - setError(err.response?.data?.message || "Registration failed"); + const message = getApiErrorMessage(err, "Registration failed"); + setError(message); + toast.error("Registration failed", `Registration failed: ${message}`); setTimeout(() => setError(""), 1000); } }; @@ -107,4 +114,4 @@ export default function Register() {

); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 5c0ce13..d4fa7cc 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,12 +1,18 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users"; import { SettingsContext } from "../context/SettingsContext"; +import useActionToast from "../hooks/useActionToast"; +import getApiErrorMessage from "../lib/getApiErrorMessage"; import "../styles/pages/Settings.css"; export default function Settings() { const { settings, updateSettings, resetSettings } = useContext(SettingsContext); + const toast = useActionToast(); const [activeTab, setActiveTab] = useState("appearance"); + const tabsRef = useRef(null); + const [showLeftArrow, setShowLeftArrow] = useState(false); + const [showRightArrow, setShowRightArrow] = useState(false); // Account management state const [displayName, setDisplayName] = useState(""); @@ -30,6 +36,35 @@ export default function Settings() { loadProfile(); }, []); + useEffect(() => { + const tabsElement = tabsRef.current; + if (!tabsElement) return; + + const updateArrowVisibility = () => { + const hasOverflow = tabsElement.scrollWidth > tabsElement.clientWidth + 1; + + if (!hasOverflow) { + setShowLeftArrow(false); + setShowRightArrow(false); + return; + } + + setShowLeftArrow(tabsElement.scrollLeft > 4); + setShowRightArrow( + tabsElement.scrollLeft + tabsElement.clientWidth < tabsElement.scrollWidth - 4 + ); + }; + + updateArrowVisibility(); + + tabsElement.addEventListener("scroll", updateArrowVisibility, { passive: true }); + window.addEventListener("resize", updateArrowVisibility); + + return () => { + tabsElement.removeEventListener("scroll", updateArrowVisibility); + window.removeEventListener("resize", updateArrowVisibility); + }; + }, []); const handleThemeChange = (theme) => { updateSettings({ theme }); @@ -43,11 +78,14 @@ export default function Settings() { try { await updateCurrentUser(displayName); setAccountMessage({ type: "success", text: "Display name updated successfully!" }); + toast.success("Updated display name", `Updated display name to ${displayName}`); } catch (error) { + const message = getApiErrorMessage(error, "Failed to update display name"); setAccountMessage({ type: "error", - text: error.response?.data?.error || "Failed to update display name" + text: message }); + toast.error("Update display name failed", `Update display name failed: ${message}`); } finally { setLoadingProfile(false); } @@ -73,14 +111,17 @@ export default function Settings() { try { await changePassword(currentPassword, newPassword); setAccountMessage({ type: "success", text: "Password changed successfully!" }); + toast.success("Changed password", "Changed password successfully"); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } catch (error) { + const message = getApiErrorMessage(error, "Failed to change password"); setAccountMessage({ type: "error", - text: error.response?.data?.error || "Failed to change password" + text: message }); + toast.error("Change password failed", `Change password failed: ${message}`); } finally { setLoadingPassword(false); } @@ -114,31 +155,47 @@ export default function Settings() {

Settings

-
- -
+ +
+ + + + +
+ +
diff --git a/frontend/src/styles/AddImageModal.css b/frontend/src/styles/AddImageModal.css index a0608d4..3f64c08 100644 --- a/frontend/src/styles/AddImageModal.css +++ b/frontend/src/styles/AddImageModal.css @@ -81,3 +81,38 @@ .add-image-remove:hover { background: rgba(255, 0, 0, 1); } + +/* Mobile Responsive Styles */ +@media (max-width: 768px) { + .add-image-preview-container { + margin: 1rem 0; + } + + .add-image-preview { + width: 200px; + height: 200px; + } + + .add-image-option-btn { + padding: 1rem; + font-size: 1rem; + min-height: 44px; + } +} + +@media (max-width: 480px) { + .add-image-preview { + width: 180px; + height: 180px; + } + + .add-image-option-btn { + font-size: 0.95rem; + } + + .add-image-remove { + width: 36px; + height: 36px; + font-size: 1.3em; + } +} diff --git a/frontend/src/styles/ConfirmBuyModal.css b/frontend/src/styles/ConfirmBuyModal.css index 7db66ea..6a64977 100644 --- a/frontend/src/styles/ConfirmBuyModal.css +++ b/frontend/src/styles/ConfirmBuyModal.css @@ -181,12 +181,14 @@ } .confirm-buy-cancel { - background: var(--color-gray-200); - color: var(--color-text-primary); + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border: 1px solid var(--button-secondary-border); } .confirm-buy-cancel:hover { - background: var(--color-gray-300); + background: var(--button-secondary-hover-bg); + border-color: var(--button-secondary-border-hover); } .confirm-buy-confirm { diff --git a/frontend/src/styles/ImageModal.css b/frontend/src/styles/ImageModal.css index fe4fbe7..1547ba2 100644 --- a/frontend/src/styles/ImageModal.css +++ b/frontend/src/styles/ImageModal.css @@ -64,3 +64,20 @@ } } +@media (max-width: 480px) { + .image-modal-overlay { + padding: 0.5rem; + } + + .image-modal-content { + padding: 0.5rem; + border-radius: 8px; + max-width: 95vw; + } + + .image-modal-img { + max-height: 50vh; + border-radius: 4px; + } +} + diff --git a/frontend/src/styles/ImageUploadModal.css b/frontend/src/styles/ImageUploadModal.css index c528556..c88a1f7 100644 --- a/frontend/src/styles/ImageUploadModal.css +++ b/frontend/src/styles/ImageUploadModal.css @@ -52,9 +52,9 @@ .image-upload-option-btn { padding: 1.2em; - border: 2px solid #ddd; + border: 2px solid var(--button-secondary-border); border-radius: 8px; - background: white; + background: var(--button-ghost-bg); font-size: 1.1em; cursor: pointer; transition: all 0.2s; @@ -65,8 +65,8 @@ } .image-upload-option-btn:hover { - border-color: #007bff; - background: #f8f9fa; + border-color: var(--button-secondary-border-hover); + background: var(--button-secondary-hover-bg); transform: translateY(-2px); box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2); } @@ -160,12 +160,13 @@ } .image-upload-skip { - background: #f0f0f0; - color: #333; + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border: 1px solid var(--button-secondary-border); } .image-upload-skip:hover { - background: #e0e0e0; + background: var(--button-secondary-hover-bg); } .image-upload-confirm { @@ -201,3 +202,67 @@ opacity: 1; } } + +/* Mobile Responsive Styles */ +@media (max-width: 768px) { + .image-upload-modal { + width: 95%; + padding: 1.5rem; + max-height: 90vh; + overflow-y: auto; + } + + .image-upload-modal h2 { + font-size: 1.3em; + } + + .image-upload-option-btn { + padding: 1rem; + font-size: 1rem; + min-height: 44px; + } + + .modal-image-preview { + width: 180px; + height: 180px; + } + + .image-upload-actions { + flex-direction: column; + gap: 0.75rem; + } + + .image-upload-cancel, + .image-upload-skip, + .image-upload-confirm { + width: 100%; + min-height: 44px; + } +} + +@media (max-width: 480px) { + .image-upload-modal { + width: 100%; + padding: 1rem; + border-radius: 8px; + } + + .image-upload-modal h2 { + font-size: 1.15em; + } + + .image-upload-subtitle { + font-size: 0.9em; + } + + .modal-image-preview { + width: 160px; + height: 160px; + } + + .modal-remove-image { + width: 36px; + height: 36px; + font-size: 1.3em; + } +} diff --git a/frontend/src/styles/ItemClassificationModal.css b/frontend/src/styles/ItemClassificationModal.css index 0232ada..99f9b8a 100644 --- a/frontend/src/styles/ItemClassificationModal.css +++ b/frontend/src/styles/ItemClassificationModal.css @@ -84,12 +84,14 @@ } .classification-modal-btn-skip { - background: #6c757d; - color: white; + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border: 1px solid var(--button-secondary-border); } .classification-modal-btn-skip:hover { - background: #5a6268; + background: var(--button-secondary-hover-bg); + border-color: var(--button-secondary-border-hover); } .classification-modal-btn-confirm { @@ -100,3 +102,52 @@ .classification-modal-btn-confirm:hover { background: #0056b3; } + +/* Mobile Responsive Styles */ +@media (max-width: 768px) { + .classification-modal-content { + width: 95%; + padding: 1.25rem; + max-height: 85vh; + overflow-y: auto; + } + + .classification-modal-title { + font-size: 1.3em; + } + + .classification-modal-subtitle { + font-size: 0.9em; + } + + .classification-modal-select { + padding: 0.7em; + font-size: 16px; /* Prevents iOS zoom */ + } + + .classification-modal-actions { + flex-direction: column; + gap: 0.6rem; + } + + .classification-modal-btn { + width: 100%; + min-height: 44px; + } +} + +@media (max-width: 480px) { + .classification-modal-content { + width: 100%; + padding: 1rem; + border-radius: 8px; + } + + .classification-modal-title { + font-size: 1.2em; + } + + .classification-modal-field label { + font-size: 0.9em; + } +} diff --git a/frontend/src/styles/SimilarItemModal.css b/frontend/src/styles/SimilarItemModal.css index 32e8279..7c6660a 100644 --- a/frontend/src/styles/SimilarItemModal.css +++ b/frontend/src/styles/SimilarItemModal.css @@ -22,3 +22,23 @@ width: 100%; } +/* Mobile Responsive Styles */ +@media (max-width: 768px) { + .similar-item-suggested, + .similar-item-original { + font-size: 1em; + } + + .similar-modal-actions .btn { + min-height: 44px; + font-size: 1rem; + } +} + +@media (max-width: 480px) { + .similar-item-suggested, + .similar-item-original { + font-size: 0.95em; + } +} + diff --git a/frontend/src/styles/components/AddItemForm.css b/frontend/src/styles/components/AddItemForm.css index acb5491..5c455e9 100644 --- a/frontend/src/styles/components/AddItemForm.css +++ b/frontend/src/styles/components/AddItemForm.css @@ -1,7 +1,7 @@ /* Add Item Form Container */ .add-item-form-container { background: var(--color-bg-surface); - padding: var(--spacing-lg); + padding: var(--spacing-md); border-radius: var(--border-radius-lg); box-shadow: var(--shadow-md); margin-bottom: var(--spacing-xs); @@ -11,7 +11,7 @@ .add-item-form { display: flex; flex-direction: column; - gap: var(--spacing-sm); + gap: var(--spacing-xs); } /* Form Fields */ @@ -21,6 +21,28 @@ position: relative; } +.add-item-form-input-row { + display: flex; + align-items: stretch; + gap: var(--spacing-xs); +} + +.add-item-form-input-row .add-item-form-field { + flex: 1; +} + +.add-item-form-assignee-toggle { + flex: 0 0 auto; + width: 112px; + margin: 0; +} + +.add-item-form-assignee-hint { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + .add-item-form-input { padding: var(--input-padding-y) var(--input-padding-x); border: var(--border-width-thin) solid var(--input-border-color); @@ -58,7 +80,8 @@ display: flex; align-items: center; justify-content: space-between; - gap: var(--spacing-md); + gap: var(--spacing-sm); + min-height: 40px; } /* Quantity Control */ @@ -66,11 +89,12 @@ display: flex; align-items: center; gap: var(--spacing-xs); + height: 40px; } .quantity-btn { width: 40px; - height: 40px; + height: 100%; border: var(--border-width-thin) solid var(--color-border-medium); background: var(--color-bg-surface); color: var(--color-text-primary); @@ -106,6 +130,8 @@ .add-item-form-quantity-input { width: 40px; max-width: 40px; + height: 100%; + box-sizing: border-box; padding: var(--input-padding-y) var(--input-padding-x); border: var(--border-width-thin) solid var(--input-border-color); border-radius: var(--input-border-radius); @@ -142,9 +168,9 @@ font-size: var(--font-size-base); font-weight: var(--button-font-weight); flex: 1; - min-width: 120px + min-width: 120px; transition: var(--transition-base); - margin-top: var(--spacing-sm); + margin-top: 0; } .add-item-form-submit:hover:not(:disabled) { @@ -173,10 +199,23 @@ .add-item-form-container { padding: var(--spacing-md); } - + + .add-item-form-assignee-toggle { + width: 100px; + } + + .add-item-form-quantity-control { + height: 36px; + } + .quantity-btn { width: 36px; - height: 36px; + height: 100%; font-size: var(--font-size-lg); } + + .add-item-form-quantity-input, + .add-item-form-submit { + height: 36px; + } } diff --git a/frontend/src/styles/components/AddItemWithDetailsModal.css b/frontend/src/styles/components/AddItemWithDetailsModal.css index da8e9eb..500981c 100644 --- a/frontend/src/styles/components/AddItemWithDetailsModal.css +++ b/frontend/src/styles/components/AddItemWithDetailsModal.css @@ -61,7 +61,7 @@ .add-item-details-image-options { display: flex; - gap: 0.8em; + gap: var(--spacing-sm); flex-wrap: wrap; } @@ -69,7 +69,7 @@ flex: 1; min-width: 140px; padding: var(--button-padding-y) var(--button-padding-x); - font-size: 0.95em; + font-size: var(--font-size-base); border: var(--border-width-medium) solid var(--color-primary); background: var(--color-bg-surface); color: var(--color-primary); @@ -101,107 +101,157 @@ .add-item-details-remove-image { position: absolute; - top: 0.5em; - right: 0.5em; - background: rgba(220, 53, 69, 0.9); - color: white; + top: var(--spacing-sm); + right: var(--spacing-sm); + background: var(--color-danger); + color: var(--color-text-inverse); border: none; - border-radius: 6px; - padding: 0.4em 0.8em; + border-radius: var(--border-radius-md); + padding: var(--spacing-sm) var(--spacing-md); cursor: pointer; - font-weight: 600; - font-size: 0.9em; - transition: background 0.2s; + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-sm); + transition: var(--transition-base); } .add-item-details-remove-image:hover { - background: rgba(220, 53, 69, 1); + background: var(--color-danger-hover); } /* Classification Section */ .add-item-details-field { - margin-bottom: 1em; + margin-bottom: var(--spacing-md); } .add-item-details-field label { display: block; - margin-bottom: 0.4em; - font-weight: 600; - color: #333; - font-size: 0.9em; + margin-bottom: var(--spacing-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + font-size: var(--font-size-sm); } .add-item-details-select { width: 100%; - padding: 0.6em; - font-size: 1em; - border: 1px solid #ccc; - border-radius: 6px; + padding: var(--input-padding-y) var(--input-padding-x); + font-size: var(--font-size-base); + border: var(--border-width-thin) solid var(--input-border-color); + border-radius: var(--input-border-radius); box-sizing: border-box; - transition: border-color 0.2s; - background: white; + transition: var(--transition-base); + background: var(--color-bg-surface); + color: var(--color-text-primary); } .add-item-details-select:focus { outline: none; - border-color: #007bff; + border-color: var(--input-focus-border-color); + box-shadow: var(--input-focus-shadow); } /* Actions */ .add-item-details-actions { display: flex; - gap: 0.6em; - margin-top: 1.5em; - padding-top: 1em; - border-top: 1px solid #e0e0e0; + gap: var(--spacing-sm); + margin-top: var(--spacing-lg); + padding-top: var(--spacing-md); + border-top: var(--border-width-thin) solid var(--color-border-light); } .add-item-details-btn { flex: 1; - padding: 0.7em; - font-size: 1em; + padding: var(--button-padding-y) var(--button-padding-x); + font-size: var(--font-size-base); border: none; - border-radius: 6px; + border-radius: var(--button-border-radius); cursor: pointer; - font-weight: 600; - transition: all 0.2s; + font-weight: var(--button-font-weight); + transition: var(--transition-base); } .add-item-details-btn.cancel { - background: #6c757d; - color: white; + background: var(--color-secondary); + color: var(--color-text-inverse); } .add-item-details-btn.cancel:hover { - background: #5a6268; + background: var(--color-secondary-hover); } .add-item-details-btn.skip { - background: #ffc107; - color: #333; + background: var(--color-warning); + color: var(--color-text-primary); } .add-item-details-btn.skip:hover { - background: #e0a800; + background: var(--color-warning-hover); } .add-item-details-btn.confirm { - background: #007bff; - color: white; + background: var(--color-primary); + color: var(--color-text-inverse); } .add-item-details-btn.confirm:hover { - background: #0056b3; + background: var(--color-primary-hover); } /* Mobile responsiveness */ -@media (max-width: 480px) { +@media (max-width: 768px) { + .add-item-details-overlay { + padding: var(--spacing-sm); + } + .add-item-details-modal { - padding: 1.2em; + width: 95%; + max-width: 95%; + padding: var(--spacing-md); } .add-item-details-title { - font-size: 1.2em; + font-size: var(--font-size-xl); + } + + .add-item-details-select { + padding: 0.7em; + font-size: 16px; /* Prevents iOS zoom */ + } + + .add-item-details-image-options { + gap: 0.6em; + } + + .add-item-details-image-btn { + min-height: 44px; + } + + .add-item-details-actions { + flex-direction: column; + gap: 0.5rem; + } + + .add-item-details-btn { + width: 100%; + min-height: 44px; + } +} + +@media (max-width: 480px) { + .add-item-details-modal { + padding: var(--spacing-md); + border-radius: var(--border-radius-lg); + } + + .add-item-details-title { + font-size: var(--font-size-lg); + } + + .add-item-details-subtitle { + font-size: var(--font-size-sm); + } + + .add-item-details-section-title { + font-size: var(--font-size-base); } .add-item-details-image-options { @@ -210,9 +260,10 @@ .add-item-details-image-btn { min-width: 100%; + font-size: var(--font-size-sm); } - .add-item-details-actions { - flex-direction: column; + .add-item-details-field label { + font-size: var(--font-size-sm); } } diff --git a/frontend/src/styles/components/AssignItemForModal.css b/frontend/src/styles/components/AssignItemForModal.css new file mode 100644 index 0000000..6ad319a --- /dev/null +++ b/frontend/src/styles/components/AssignItemForModal.css @@ -0,0 +1,93 @@ +.assign-item-for-modal { + width: min(420px, calc(100vw - (2 * var(--spacing-md)))); + max-width: 420px; + overflow-x: hidden; + overflow-y: visible; +} + +.assign-item-for-modal-field { + margin-bottom: var(--spacing-sm); + width: 100%; + min-width: 0; +} + +.assign-item-for-dropdown { + position: relative; + width: 100%; + min-width: 0; +} + +.assign-item-for-dropdown-trigger { + width: 100%; + min-width: 0; + max-width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-xs); + padding: var(--input-padding-y) var(--input-padding-x); + border: var(--border-width-thin) solid var(--input-border-color); + border-radius: var(--input-border-radius); + background: var(--color-bg-surface); + color: var(--color-text-primary); + font-size: var(--font-size-base); + text-align: left; + cursor: pointer; +} + +.assign-item-for-dropdown-trigger.is-open, +.assign-item-for-dropdown-trigger:focus-visible { + outline: none; + border-color: var(--input-focus-border-color); + box-shadow: var(--input-focus-shadow); +} + +.assign-item-for-dropdown-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assign-item-for-dropdown-caret { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +.assign-item-for-dropdown-menu { + position: fixed; + z-index: var(--z-tooltip); + overflow-y: auto; + overscroll-behavior: contain; + background: var(--color-bg-surface); + border: var(--border-width-thin) solid var(--input-border-color); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-lg); +} + +.assign-item-for-dropdown-option { + width: 100%; + display: block; + text-align: left; + margin: 0; + border: 0; + border-radius: 0; + padding: 10px var(--input-padding-x); + background: transparent; + color: var(--color-text-primary); + cursor: pointer; +} + +.assign-item-for-dropdown-option:hover { + background: var(--color-bg-hover); +} + +.assign-item-for-dropdown-option.is-selected { + background: var(--color-primary-light); +} + +.assign-item-for-modal-empty { + margin: 0 0 var(--spacing-sm) 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} diff --git a/frontend/src/styles/components/AvailableItemEditorModal.css b/frontend/src/styles/components/AvailableItemEditorModal.css new file mode 100644 index 0000000..326a58e --- /dev/null +++ b/frontend/src/styles/components/AvailableItemEditorModal.css @@ -0,0 +1,109 @@ +.available-item-editor-overlay { + position: fixed; + inset: 0; + background: var(--modal-backdrop-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal); + padding: var(--spacing-md); +} + +.available-item-editor-modal { + width: min(560px, 100%); + max-height: 90vh; + overflow-y: auto; + background: var(--modal-bg); + border-radius: var(--border-radius-xl); + box-shadow: var(--shadow-xl); + padding: var(--spacing-lg); +} + +.available-item-editor-title { + margin: 0; + color: var(--color-text-primary); + font-size: var(--font-size-xl); +} + +.available-item-editor-subtitle { + margin: var(--spacing-xs) 0 var(--spacing-lg); + color: var(--color-text-secondary); +} + +.available-item-editor-section { + margin-top: var(--spacing-lg); +} + +.available-item-editor-field { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-md); +} + +.available-item-editor-field label { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); +} + +.available-item-editor-input, +.available-item-editor-select { + width: 100%; + box-sizing: border-box; + padding: var(--input-padding-y) var(--input-padding-x); + border: var(--border-width-thin) solid var(--input-border-color); + border-radius: var(--input-border-radius); + background: var(--color-bg-surface); + color: var(--color-text-primary); +} + +.available-item-editor-input:focus, +.available-item-editor-select:focus { + outline: none; + border-color: var(--input-focus-border-color); + box-shadow: var(--input-focus-shadow); +} + +.available-item-editor-actions { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-lg); +} + +.available-item-editor-btn { + flex: 1; + min-height: 42px; + border: none; + border-radius: var(--button-border-radius); + font-weight: var(--button-font-weight); + cursor: pointer; + transition: var(--transition-base); +} + +.available-item-editor-btn-cancel { + background: var(--color-secondary); + color: var(--color-text-inverse); +} + +.available-item-editor-btn-cancel:hover:not(:disabled) { + background: var(--color-secondary-hover); +} + +.available-item-editor-btn-save { + background: var(--color-primary); + color: var(--color-text-inverse); +} + +.available-item-editor-btn-save:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +@media (max-width: 640px) { + .available-item-editor-modal { + padding: var(--spacing-md); + } + + .available-item-editor-actions { + flex-direction: column; + } +} diff --git a/frontend/src/styles/components/ConfirmAddExistingModal.css b/frontend/src/styles/components/ConfirmAddExistingModal.css index 96033f9..d916597 100644 --- a/frontend/src/styles/components/ConfirmAddExistingModal.css +++ b/frontend/src/styles/components/ConfirmAddExistingModal.css @@ -39,3 +39,30 @@ font-size: var(--font-size-lg); } +/* Mobile Responsive Styles */ +@media (max-width: 768px) { + .confirm-add-existing-qty-info { + padding: var(--spacing-sm); + } + + .qty-row { + font-size: 0.95em; + } + + .qty-value { + font-size: 1em; + } + + .qty-total .qty-label, + .qty-total .qty-value { + font-size: 1em; + } +} + +@media (max-width: 480px) { + .qty-row { + font-size: 0.9em; + padding: var(--spacing-xxs) 0; + } +} + diff --git a/frontend/src/styles/components/ConfirmSlideModal.css b/frontend/src/styles/components/ConfirmSlideModal.css new file mode 100644 index 0000000..3ad6ca6 --- /dev/null +++ b/frontend/src/styles/components/ConfirmSlideModal.css @@ -0,0 +1,144 @@ +.confirm-slide-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + background: var(--modal-backdrop-bg); +} + +.confirm-slide-modal { + width: 100%; + max-width: 420px; + background: var(--modal-bg); + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: var(--border-radius-xl); + box-shadow: var(--shadow-xl); + padding: var(--spacing-lg); +} + +.confirm-slide-title { + margin: 0; + font-size: var(--font-size-xl); + color: var(--color-text-primary); +} + +.confirm-slide-description { + margin: var(--spacing-sm) 0 0; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.confirm-slide-track-wrap { + margin-top: var(--spacing-lg); +} + +.confirm-slide-helper { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.confirm-slide-track { + position: relative; + margin-top: var(--spacing-sm); + height: 40px; + border-radius: 999px; + border: var(--border-width-thin) solid var(--color-border-medium); + background: var(--color-bg-body); + overflow: hidden; + user-select: none; +} + +.confirm-slide-track.is-active { + border-color: var(--color-primary); + background: var(--color-primary-light); +} + +.confirm-slide-progress { + position: absolute; + inset: 0 auto 0 0; + border-radius: 999px; + background: rgba(30, 144, 255, 0.2); + min-width: 40px; + z-index: 1; + pointer-events: none; +} + +.confirm-slide-ready { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%) scale(0.9); + opacity: 0; + z-index: 2; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-primary); + white-space: nowrap; + transition: var(--transition-fast); + pointer-events: none; +} + +.confirm-slide-ready.is-visible { + opacity: 1; + transform: translate(-50%, -50%) scale(1); +} + +.confirm-slide-handle { + position: absolute; + top: 0; + left: 0; + z-index: 3; + width: 40px; + height: 40px; + margin: 0; + padding: 0; + border-radius: 50%; + border: var(--border-width-thin) solid var(--color-primary); + background: var(--modal-bg); + color: var(--color-text-primary); + font-size: 1.1rem; + font-weight: var(--font-weight-semibold); + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + transition: box-shadow var(--transition-fast), border-color var(--transition-fast); + touch-action: none; +} + +.confirm-slide-handle:active { + cursor: grabbing; +} + +.confirm-slide-handle.is-active { + border-color: var(--color-primary-dark); + box-shadow: 0 0 0 2px rgba(30, 144, 255, 0.2); +} + +.confirm-slide-footer { + margin-top: var(--spacing-lg); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); +} + +.confirm-slide-label { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.confirm-slide-cancel { + width: auto; + margin: 0; +} + +@media (max-width: 480px) { + .confirm-slide-modal { + padding: var(--spacing-md); + } +} diff --git a/frontend/src/styles/components/EditItemModal.css b/frontend/src/styles/components/EditItemModal.css index 9613520..1e3973c 100644 --- a/frontend/src/styles/components/EditItemModal.css +++ b/frontend/src/styles/components/EditItemModal.css @@ -187,3 +187,83 @@ opacity: 0.6; cursor: not-allowed; } + +/* Mobile Responsive Styles */ +@media (max-width: 768px) { + .edit-modal-overlay { + padding: var(--spacing-sm); + } + + .edit-modal-content { + width: 95%; + max-width: 95%; + padding: var(--spacing-md); + max-height: 90vh; + } + + .edit-modal-title { + font-size: 1.3em; + } + + .edit-modal-input, + .edit-modal-select { + font-size: 16px; /* Prevents iOS zoom */ + } + + .edit-modal-quantity-input { + width: 70px; + font-size: 16px; /* Prevents iOS zoom */ + } + + .quantity-btn { + width: 44px; + height: 44px; + font-size: 1.4em; + } + + .edit-modal-inline-field { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-xs); + } + + .edit-modal-inline-field label { + min-width: auto; + } + + .edit-modal-inline-field .edit-modal-select { + width: 100%; + } + + .edit-modal-actions { + flex-direction: column; + gap: var(--spacing-xs); + } + + .edit-modal-btn { + width: 100%; + min-height: 44px; + } +} + +@media (max-width: 480px) { + .edit-modal-content { + width: 100%; + padding: 1rem; + border-radius: 8px; + } + + .edit-modal-title { + font-size: 1.2em; + } + + .edit-modal-quantity-control { + gap: var(--spacing-xs); + } + + .quantity-btn { + width: 40px; + height: 40px; + font-size: 1.2em; + } +} diff --git a/frontend/src/styles/components/HouseholdSwitcher.css b/frontend/src/styles/components/HouseholdSwitcher.css new file mode 100644 index 0000000..468b9fd --- /dev/null +++ b/frontend/src/styles/components/HouseholdSwitcher.css @@ -0,0 +1,139 @@ +.household-switcher { + position: relative; + display: inline-block; + min-width: 220px; +} + +.household-switcher-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 1rem; + background: var(--button-ghost-bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.household-switcher-toggle:hover { + background: var(--button-ghost-hover-bg); + border-color: var(--button-ghost-border-hover); +} + +.household-switcher-toggle:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.household-switcher-cta { + justify-content: center; + font-weight: 600; + color: var(--primary); +} + +.household-switcher-empty .household-switcher-toggle { + width: 100%; +} + +.household-name { + flex: 1; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.dropdown-icon { + margin-left: auto; + flex-shrink: 0; + font-size: 0.75rem; + transition: transform 0.2s ease; +} + +.dropdown-icon.open { + transform: rotate(180deg); +} + +.household-switcher-overlay { + position: fixed; + inset: 0; + z-index: 999; +} + +.household-switcher-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + width: 100%; + overflow: hidden; + background: var(--card-bg); + border: 2px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + z-index: 1000; +} + +.household-option { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.875rem 1rem; + background: var(--card-bg); + border: none; + border-bottom: 1px solid var(--border); + color: var(--text-primary); + font-size: 1rem; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.household-option:last-child { + border-bottom: none; +} + +.household-option:hover { + background: var(--button-secondary-bg); + border-color: var(--primary); +} + +.household-option.active { + background: color-mix(in srgb, var(--primary) 15%, transparent); + color: var(--primary); + font-weight: 600; +} + +.check-mark { + color: var(--primary); + font-size: 1.1rem; + font-weight: bold; +} + +.household-divider { + height: 1px; + margin: 0.25rem 0; + background: var(--border); +} + +.create-household-btn { + color: var(--primary); + font-weight: 600; +} + +.create-household-btn:hover { + background: color-mix(in srgb, var(--primary) 15%, transparent); +} + +@media (max-width: 640px) { + .household-switcher { + min-width: 180px; + } +} diff --git a/frontend/src/styles/components/ImageUploadSection.css b/frontend/src/styles/components/ImageUploadSection.css index fd30b43..e057422 100644 --- a/frontend/src/styles/components/ImageUploadSection.css +++ b/frontend/src/styles/components/ImageUploadSection.css @@ -54,12 +54,14 @@ } .image-upload-btn.gallery { - background: #6c757d; - color: white; + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border: 1px solid var(--button-secondary-border); } .image-upload-btn.gallery:hover { - background: #545b62; + background: var(--button-secondary-hover-bg); + border-color: var(--button-secondary-border-hover); } .image-upload-preview { diff --git a/frontend/src/styles/components/Navbar.css b/frontend/src/styles/components/Navbar.css index 30d23a2..579478e 100644 --- a/frontend/src/styles/components/Navbar.css +++ b/frontend/src/styles/components/Navbar.css @@ -1,58 +1,302 @@ +/* Navbar - Sticky at top */ .navbar { + position: sticky; + top: 0; + z-index: 1000; background: #343a40; color: white; - padding: 0.6em 1em; - display: flex; - justify-content: space-between; + padding: 0.75rem 1rem; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - border-radius: 4px; - margin-bottom: 1em; + gap: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } -.navbar-links a { - color: white; - margin-right: 1em; +/* Navbar Sections */ +.navbar-section { + display: flex; + align-items: center; +} + +.navbar-center { + grid-column: 2; + display: flex; + justify-content: center; + min-width: 0; + justify-self: center; +} + +.navbar-right { + grid-column: 3; + justify-self: end; + flex: 0 0 auto; + position: relative; +} + +.navbar-spacer { + grid-column: 1; +} + +.navbar-center-nav { + display: flex; + align-items: center; + gap: 0; + width: 100%; + justify-content: center; +} + +.navbar-household-wrap { + position: relative; + width: 17rem; + flex: 0 0 17rem; + margin: 0; +} + +.navbar .household-switcher { + display: block; + width: 100%; +} + +.navbar-icon-link { + width: 48px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; text-decoration: none; - font-size: 1.1em; + color: #ffffff; + background: rgba(30, 144, 255, 0.18); + border: 1px solid rgba(95, 178, 255, 0.32); + border-radius: 0; + font-size: 1.2rem; + line-height: 1; + transition: background 0.2s; } -.navbar-links a:hover { - text-decoration: underline; +.navbar-icon-link:hover { + background: rgba(30, 144, 255, 0.3); } -.navbar-logout { +.navbar-icon-link:focus-visible { + outline: 2px solid #9ec5fe; + outline-offset: 2px; +} + +.navbar-icon-left { + margin-right: 0; + border-radius: 8px 0 0 8px; + border-right: none; +} + +.navbar-icon-right { + margin-left: 0; + border-radius: 0 8px 8px 0; + border-left: none; +} + +.navbar .household-switcher-toggle { + border-radius: 0; + height: 40px; + padding: 0 0.75rem; +} + +/* User Button */ +.navbar-user-btn { + background: rgba(30, 144, 255, 0.18); + color: white; + border: 1px solid rgba(95, 178, 255, 0.32); + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + white-space: nowrap; + transition: background 0.2s; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.navbar-user-btn:hover { + background: rgba(30, 144, 255, 0.3); +} + +.navbar-user-icon { + width: 18px; + height: 18px; + display: none; + color: #ffffff; +} + +.navbar-user-icon svg { + width: 100%; + height: 100%; + fill: currentColor; + display: block; +} + +.navbar-user-name { + display: inline-block; +} + +/* Dropdown Overlay */ +.menu-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 999; + background: transparent; +} + +/* Dropdown Base Styles */ +.navbar-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + background: #fff; + border: 1px solid #dee2e6; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1001; + min-width: 180px; + overflow: hidden; +} + +/* Navigation Dropdown */ +.user-dropdown { + right: 0; + min-width: 200px; +} + +.user-dropdown-info { + padding: 1rem 1.25rem; + border-bottom: 1px solid #dee2e6; + background: #f8f9fa; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.user-dropdown-username { + font-size: 1rem; + font-weight: 600; + color: #343a40; +} + +.user-dropdown-role { + font-size: 0.85rem; + color: #6c757d; + text-transform: capitalize; +} + +.user-dropdown-link { + display: block; + width: 100%; + padding: 0.75rem 1.25rem; + color: #343a40; + text-decoration: none; + border-bottom: 1px solid #f0f0f0; + transition: background 0.2s; +} + +.user-dropdown-link:hover { + background: #f8f9fa; +} + +.user-dropdown-logout { + width: 100%; background: #dc3545; color: white; border: none; - padding: 0.4em 0.8em; - border-radius: 4px; + padding: 0.75rem 1.25rem; cursor: pointer; - width: 100px; + font-size: 1rem; + font-weight: 500; + transition: background 0.2s; } -.navbar-idcard { - display: flex; - align-items: center; - align-content: center; - margin-right: 1em; - padding: 0.3em 0.6em; - background: #495057; - border-radius: 4px; - color: white; +.user-dropdown-logout:hover { + background: #c82333; } -.navbar-idinfo { - display: flex; - flex-direction: column; - line-height: 1.1; +/* Mobile Responsive */ +@media (max-width: 768px) { + .navbar { + padding: 0.5rem 0.75rem; + gap: 0.5rem; + } + + .navbar-household-wrap { + width: 14rem; + flex: 0 0 14rem; + } + + .navbar-icon-link { + width: 42px; + height: 40px; + font-size: 1rem; + } + + .navbar-icon-left { + margin-right: 0; + } + + .navbar-icon-right { + margin-left: 0; + } + + .navbar-user-btn { + padding: 0.4rem 0.75rem; + font-size: 0.9rem; + } + + .user-dropdown { + min-width: 180px; + } } -.navbar-username { - font-size: 0.95em; - font-weight: bold; +@media (max-width: 480px) { + .navbar { + padding: 0.5rem; + grid-template-columns: auto 1fr auto; + } + + .navbar-household-wrap { + width: 11rem; + flex: 0 0 11rem; + } + + .navbar-icon-left { + margin-right: 0; + } + + .navbar-icon-right { + margin-left: 0; + } } -.navbar-role { - font-size: 0.75em; - opacity: 0.8; +@media (max-width: 360px) { + .navbar-household-wrap { + width: 10rem; + flex: 0 0 10rem; + } +} + +@media (max-width: 900px) { + .navbar-user-btn { + width: 40px; + height: 40px; + padding: 0; + justify-content: center; + } + + .navbar-user-icon { + display: inline-flex; + } + + .navbar-user-name { + display: none; + } } diff --git a/frontend/src/styles/components/NoHouseholdState.css b/frontend/src/styles/components/NoHouseholdState.css new file mode 100644 index 0000000..ef64c6e --- /dev/null +++ b/frontend/src/styles/components/NoHouseholdState.css @@ -0,0 +1,76 @@ +.no-household-state { + display: flex; + justify-content: center; + margin: 2rem auto 0; +} + +.no-household-card { + width: min(100%, 38rem); + padding: 2rem; + border: 1px solid var(--border); + border-radius: 20px; + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--primary) 14%, transparent), transparent 38%), + linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 96%, white), var(--card-bg)); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.12); + text-align: center; +} + +.no-household-eyebrow { + margin: 0 0 0.5rem; + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--primary); +} + +.no-household-title { + margin: 0; + font-size: clamp(1.75rem, 4vw, 2.4rem); + color: var(--text-primary); +} + +.no-household-description { + margin: 1rem 0 0; + color: var(--text-secondary); + font-size: 1rem; + line-height: 1.6; +} + +.no-household-error { + margin: 1rem 0 0; + padding: 0.85rem 1rem; + border-radius: 12px; + background: var(--danger-light, rgba(220, 53, 69, 0.1)); + color: var(--danger); + border: 1px solid color-mix(in srgb, var(--danger) 50%, transparent); +} + +.no-household-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.no-household-action { + min-width: 11rem; + min-height: 44px; +} + +@media (max-width: 640px) { + .no-household-card { + padding: 1.5rem; + border-radius: 16px; + } + + .no-household-actions { + flex-direction: column; + } + + .no-household-action { + width: 100%; + } +} diff --git a/frontend/src/styles/components/StoreTabs.css b/frontend/src/styles/components/StoreTabs.css new file mode 100644 index 0000000..588a18a --- /dev/null +++ b/frontend/src/styles/components/StoreTabs.css @@ -0,0 +1,78 @@ +.store-tabs { + background: var(--surface-color); + border-bottom: 2px solid var(--border-color); + margin-bottom: 1.5rem; +} + +.store-tabs-container { + display: flex; + justify-content: center; + gap: 0.25rem; + overflow-x: auto; + padding: 0.5rem 1rem 0; + width: 100%; +} + +.store-tabs-container::-webkit-scrollbar { + height: 4px; +} + +.store-tabs-container::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 2px; +} + +.store-tab { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + color: var(--text-secondary); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + text-align: center; +} + +.store-tab:hover { + color: var(--text-color); + background: var(--hover-color); +} + +.store-tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.store-tab:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.store-name { + font-weight: 500; +} + +.default-badge { + padding: 0.125rem 0.5rem; + background: var(--primary-color-light); + color: var(--primary-color); + font-size: 0.75rem; + font-weight: 600; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.store-tabs-empty { + padding: 1rem; + text-align: center; + color: var(--text-secondary); + font-style: italic; +} diff --git a/frontend/src/styles/components/ToggleButtonGroup.css b/frontend/src/styles/components/ToggleButtonGroup.css new file mode 100644 index 0000000..73085d5 --- /dev/null +++ b/frontend/src/styles/components/ToggleButtonGroup.css @@ -0,0 +1,87 @@ +.tbg-group { + position: relative; + display: grid; + grid-template-columns: repeat(var(--tbg-option-count, 1), minmax(0, 1fr)); + align-items: stretch; + gap: 0; + padding: 2px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--background); + overflow: hidden; + isolation: isolate; +} + +.tbg-indicator { + position: absolute; + top: 2px; + bottom: 2px; + left: 2px; + width: calc((100% - 4px) / var(--tbg-option-count, 1)); + border-radius: 999px; + background: var(--primary); + transform: translateX(calc(var(--tbg-active-index, 0) * 100%)); + transition: transform 0.22s ease, opacity 0.2s ease; + opacity: 0; + z-index: 0; +} + +.tbg-group.has-active .tbg-indicator { + opacity: 1; +} + +.tbg-button { + position: relative; + z-index: 1; + margin: 0; + width: 100%; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: color var(--transition-fast), background-color var(--transition-fast); + white-space: nowrap; +} + +.tbg-button.tbg-size-default { + padding: 0.5rem 0.8rem; + font-size: 0.9rem; + font-weight: 500; +} + +.tbg-button.tbg-size-xs { + padding: 0.35rem 0.5rem; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + +.tbg-button.tbg-size-xxs { + padding: 0.25rem 0.38rem; + font-size: 0.68rem; + font-weight: var(--font-weight-semibold); +} + +.tbg-button.is-active { + color: var(--color-text-inverse); + background: transparent; +} + +.tbg-button.is-inactive:hover:not(:disabled) { + color: var(--text-primary); + background: var(--button-secondary-bg); +} + +[data-theme="dark"] .tbg-button.is-inactive:hover:not(:disabled) { + background: var(--button-secondary-bg); +} + +.tbg-button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +.tbg-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/frontend/src/styles/components/UploadToaster.css b/frontend/src/styles/components/UploadToaster.css new file mode 100644 index 0000000..3497023 --- /dev/null +++ b/frontend/src/styles/components/UploadToaster.css @@ -0,0 +1,124 @@ +.upload-toaster { + position: fixed; + right: 1rem; + bottom: 1rem; + display: flex; + flex-direction: column; + gap: 0.6rem; + z-index: 1200; + max-width: min(90vw, 22rem); +} + +.upload-toast { + background: var(--color-bg-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-border-light); + border-radius: 10px; + padding: 0.7rem 0.8rem; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); +} + +.upload-toast-title { + font-size: 0.95rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-toast-status { + margin-top: 0.15rem; + font-size: 0.82rem; + color: var(--color-text-secondary); +} + +.upload-toast-progress { + margin-top: 0.45rem; + width: 100%; + height: 6px; + border-radius: 999px; + background: var(--color-border-light); + overflow: hidden; +} + +.upload-toast-progress-fill { + height: 100%; + width: 0; + background: var(--color-primary); + transition: width 0.2s ease; +} + +.upload-toast-actions { + margin-top: 0.55rem; + display: flex; + gap: 0.4rem; +} + +.upload-toast-actions button { + border: 1px solid var(--button-secondary-border); + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border-radius: 6px; + padding: 0.3rem 0.55rem; + font-size: 0.8rem; + cursor: pointer; +} + +.upload-toast-actions button:hover { + background: var(--button-secondary-hover-bg); + border-color: var(--button-secondary-border-hover); + color: var(--button-secondary-text); +} + +.upload-toast-success .upload-toast-progress-fill { + background: var(--color-success); +} + +.upload-toast-failed .upload-toast-progress-fill { + background: var(--color-danger); +} + +.action-toast { + border-left: 4px solid var(--color-primary); +} + +.action-toast-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; +} + +.action-toast-close { + border: none; + background: transparent; + color: var(--color-text-secondary); + font-size: 0.95rem; + line-height: 1; + cursor: pointer; + padding: 0.1rem 0.2rem; +} + +.action-toast-close:hover { + color: var(--color-text-primary); +} + +.action-toast-success { + border-left-color: var(--color-success); +} + +.action-toast-error { + border-left-color: var(--color-danger); +} + +.action-toast-info { + border-left-color: var(--color-primary); +} + +@media (max-width: 640px) { + .upload-toaster { + right: 0.6rem; + left: 0.6rem; + max-width: none; + } +} diff --git a/frontend/src/styles/components/admin/StoreManagement.css b/frontend/src/styles/components/admin/StoreManagement.css new file mode 100644 index 0000000..60c4320 --- /dev/null +++ b/frontend/src/styles/components/admin/StoreManagement.css @@ -0,0 +1,278 @@ +/* Store Management Component */ +.store-management { + max-width: 1000px; + margin: 0 auto; + padding: 2rem; +} + +.store-management-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.store-management-header h2 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +/* Form Card */ +.store-form-card { + background: var(--card-bg); + border: 2px solid var(--primary); + border-radius: 8px; + padding: 2rem; + margin-bottom: 2rem; +} + +.store-form-card h3 { + font-size: 1.3rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 1.5rem 0; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 1rem; + background: var(--background); + color: var(--text-primary); + font-family: inherit; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); +} + +.form-group textarea { + resize: vertical; + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +.form-hint { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +/* Zone Input Section */ +.zone-input-container { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.zone-input-container input { + flex: 1 1 300px; + width: auto !important; + min-width: 200px; + max-width: 100%; +} + +.zone-input-container .btn-small { + flex: 0 0 auto; + padding: 0.5rem 1rem; + font-size: 0.9rem; + white-space: nowrap; +} + +/* Zones List (Chips in Form) */ +.zones-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0.75rem; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + min-height: 50px; +} + +.zone-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--color-primary); + color: white; + padding: 0.4rem 0.7rem; + border-radius: 16px; + font-size: 0.9rem; + line-height: 1; +} + +.zone-remove { + background: none; + border: none; + color: white; + font-size: 1.3rem; + line-height: 1; + cursor: pointer; + padding: 0; + margin: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background 0.2s; +} + +.zone-remove:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Store Zones Display (in cards) */ +.store-zones-display { + margin-top: 0.75rem; +} + +.zones-label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.zones-list-display { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.zone-badge { + display: inline-block; + background: var(--color-primary); + color: white; + padding: 0.3rem 0.7rem; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 500; +} + +.form-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 2rem; +} + +/* Stores Grid */ +.stores-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.store-admin-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + transition: all 0.2s; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.store-admin-card:hover { + border-color: var(--primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.store-admin-info h3 { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 0.75rem 0; +} + +.store-zones { + color: var(--text-secondary); + font-size: 0.9rem; + margin: 0.5rem 0; + word-break: break-word; +} + +.store-meta { + color: var(--text-secondary); + font-size: 0.85rem; + margin: 0.5rem 0 0 0; + font-family: monospace; +} + +.store-admin-actions { + display: flex; + gap: 0.75rem; + margin-top: auto; +} + +.store-admin-actions button { + flex: 1; +} + +.empty-message { + text-align: center; + color: var(--text-secondary); + padding: 3rem; + grid-column: 1 / -1; +} + +/* Responsive */ +@media (max-width: 768px) { + .store-management { + padding: 1rem; + } + + .store-management-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .store-management-header button { + width: 100%; + } + + .stores-grid { + grid-template-columns: 1fr; + } + + .store-form-card { + padding: 1.5rem; + } + + .form-actions { + flex-direction: column-reverse; + } + + .form-actions button { + width: 100%; + } +} diff --git a/frontend/src/styles/components/manage/CreateJoinHousehold.css b/frontend/src/styles/components/manage/CreateJoinHousehold.css new file mode 100644 index 0000000..18a50fe --- /dev/null +++ b/frontend/src/styles/components/manage/CreateJoinHousehold.css @@ -0,0 +1,165 @@ +/* Create/Join Household Modal */ +.create-join-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.create-join-modal { + background: var(--card-bg); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + max-width: 500px; + width: 100%; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.close-btn { + background: none; + border: none; + font-size: 2rem; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.close-btn:hover { + background: var(--button-ghost-bg); + color: var(--text-primary); +} + +/* Mode Tabs */ +.mode-tabs { + display: flex; + border-bottom: 2px solid var(--border); +} + +.mode-tab { + flex: 1; + padding: 1rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + color: var(--text-secondary); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.mode-tab:hover { + color: var(--text-primary); + background: var(--button-secondary-bg); +} + +.mode-tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +/* Form */ +.household-form { + padding: 1.5rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 1rem; + background: var(--background); + color: var(--text-primary); + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: var(--primary); +} + +.form-hint { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.error-message { + margin: 1rem 1.5rem; + padding: 0.75rem; + background: var(--danger-light, rgba(220, 53, 69, 0.1)); + border: 1px solid var(--danger); + border-radius: 6px; + color: var(--danger); + font-size: 0.9rem; +} + +.form-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +.form-actions button { + min-width: 100px; +} + +/* Responsive */ +@media (max-width: 600px) { + .create-join-modal { + margin: 0; + border-radius: 0; + max-height: 100vh; + height: 100%; + } + + .form-actions { + flex-direction: column-reverse; + } + + .form-actions button { + width: 100%; + } +} diff --git a/frontend/src/styles/components/manage/ManageHousehold.css b/frontend/src/styles/components/manage/ManageHousehold.css new file mode 100644 index 0000000..d88d423 --- /dev/null +++ b/frontend/src/styles/components/manage/ManageHousehold.css @@ -0,0 +1,653 @@ +/* Manage Household Component */ +.manage-household { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +.manage-section { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + box-sizing: border-box; + padding: 1.2rem 1.25rem; + border: 1px solid var(--color-border-light); + border-radius: var(--border-radius-lg); + background: + linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0.97)), + var(--card-bg); + box-shadow: var(--shadow-sm); +} + +[data-theme="dark"] .manage-section, +body.dark-mode .manage-section { + background: + linear-gradient(180deg, rgba(26, 37, 52, 0.98), rgba(18, 27, 40, 0.94)), + var(--card-bg); + border-color: var(--color-border-medium); +} + +.manage-section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.manage-section-header h2 { + margin: 0.15rem 0 0; + font-size: 1.2rem; + color: var(--text-primary); +} + +.manage-section-eyebrow { + margin: 0; + color: var(--primary); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.section-description { + color: var(--text-secondary); + font-size: 0.92rem; + line-height: 1.55; + margin: 0.45rem 0 0; +} + +.section-error { + color: var(--danger); + margin: 0; + padding: 0.8rem 0.95rem; + border-radius: var(--border-radius-md); + border: 1px solid color-mix(in srgb, var(--danger) 28%, transparent); + background: color-mix(in srgb, var(--danger-light) 78%, white); +} + +/* Household Name Section */ +.name-display { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.name-display-copy { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.name-display h3 { + font-size: 1.55rem; + color: var(--text-primary); + margin: 0; +} + +.household-summary-chips { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.household-summary-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.75rem; + border-radius: var(--border-radius-full); + background: var(--primary-light); + color: var(--primary-dark); + font-size: 0.82rem; + font-weight: 600; +} + +.edit-name-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; +} + +.edit-name-form input { + min-width: 0; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: var(--border-radius-md); + font-size: 0.98rem; + background: rgba(255, 255, 255, 0.82); + color: var(--text-primary); +} + +[data-theme="dark"] .edit-name-form input, +body.dark-mode .edit-name-form input { + background: rgba(12, 19, 30, 0.92); + border-color: var(--color-border-medium); +} + +.manage-household-join-policy-toggle { + margin-bottom: 0.2rem; +} + +.pending-requests-summary { + display: inline-flex; + align-items: center; + gap: 0.65rem; + width: fit-content; + padding: 0.45rem 0.8rem; + border-radius: var(--border-radius-full); + background: rgba(30, 144, 255, 0.1); + color: var(--primary-dark); +} + +.pending-requests-summary-label { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.pending-requests-summary-count { + min-width: 1.85rem; + height: 1.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--primary); + color: var(--color-text-inverse); + font-size: 0.85rem; + font-weight: 700; +} + +[data-theme="dark"] .pending-requests-summary, +body.dark-mode .pending-requests-summary { + background: rgba(95, 178, 255, 0.14); + color: #d8ecff; +} + +.pending-requests-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.pending-request-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: center; + padding: 0.95rem 1rem; + border: 1px solid var(--border); + border-radius: var(--border-radius-lg); + background: color-mix(in srgb, var(--primary-light) 40%, white); +} + +[data-theme="dark"] .pending-request-card, +body.dark-mode .pending-request-card { + background: rgba(14, 27, 45, 0.96); + border-color: var(--color-border-medium); +} + +.pending-request-main { + min-width: 0; +} + +.pending-request-topline { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + margin-bottom: 0.35rem; +} + +.pending-request-name, +.pending-request-meta { + margin: 0; +} + +.pending-request-name { + font-weight: 700; + color: var(--text-primary); +} + +.pending-request-meta { + color: var(--text-secondary); + font-size: 0.84rem; + line-height: 1.5; +} + +.pending-request-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.28rem 0.6rem; + border-radius: var(--border-radius-full); + background: rgba(245, 158, 11, 0.18); + color: #b45309; + font-size: 0.78rem; + font-weight: 700; +} + +.pending-request-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.invite-controls { + display: grid; + grid-template-columns: repeat(2, minmax(140px, 180px)) auto; + gap: 0.8rem; + align-items: end; +} + +.invite-controls label { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: var(--text-primary); + font-size: 0.9rem; +} + +.invite-control-label { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-secondary); + text-transform: uppercase; +} + +.invite-controls select { + min-width: 120px; + border: 1px solid var(--border); + border-radius: var(--border-radius-md); + padding: 0.7rem 0.75rem; + background: rgba(255, 255, 255, 1); + color: var(--text-primary); +} + +[data-theme="dark"] .invite-controls select, +body.dark-mode .invite-controls select { + background: rgba(12, 19, 30, 0.92); + border-color: var(--color-border-medium); +} + +.invite-links-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.invite-link-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: center; + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: var(--border-radius-lg); + background: rgba(255, 255, 255, 1); +} + +[data-theme="dark"] .invite-link-card, +body.dark-mode .invite-link-card { + background: rgba(12, 19, 30, 0.9); + border-color: var(--color-border-medium); +} + +.invite-link-main { + min-width: 0; +} + +.invite-link-topline { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + margin-bottom: 0.35rem; +} + +.invite-link-token, +.invite-link-meta { + margin: 0; +} + +.invite-link-token { + font-weight: 700; + color: var(--text-primary); +} + +.invite-link-meta { + color: var(--text-secondary); + font-size: 0.84rem; + line-height: 1.5; +} + +.invite-status-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.28rem 0.6rem; + border-radius: var(--border-radius-full); + font-size: 0.78rem; + font-weight: 700; +} + +.invite-status-badge.is-active { + background: var(--success-light); + color: var(--success); +} + +.invite-status-badge.is-used { + background: color-mix(in srgb, var(--color-gray-200) 74%, white); + color: var(--color-gray-700); +} + +[data-theme="dark"] .invite-status-badge.is-used, +body.dark-mode .invite-status-badge.is-used { + background: rgba(100, 116, 139, 0.22); + color: #d9e2ef; +} + +.invite-status-badge.is-revoked { + background: var(--danger-light); + color: var(--danger); +} + +.invite-status-badge.is-expired { + background: var(--color-warning-light); + color: var(--color-warning); +} + +.invite-link-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +/* Members Section */ +.members-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); + gap: 0.85rem; +} + +.member-card { + display: flex; + flex-direction: column; + gap: 0.9rem; + padding: 0.95rem 1rem; + background: rgba(255, 255, 255, 1); + border: 1px solid var(--border); + border-radius: var(--border-radius-lg); + transition: all 0.2s; +} + +[data-theme="dark"] .member-card, +body.dark-mode .member-card { + background: rgba(12, 19, 30, 0.9); + border-color: var(--color-border-medium); +} + +.member-card:hover { + background: var(--card-hover); + border-color: var(--primary); + transform: translateY(-1px); +} + +[data-theme="dark"] .member-card:hover, +body.dark-mode .member-card:hover { + background: rgba(20, 32, 48, 0.98); +} + +.member-avatar { + width: 2.6rem; + height: 2.6rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--primary-light); + font-size: 1.15rem; +} + +.member-main { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.85rem; + align-items: flex-start; +} + +.member-info { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.member-topline { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.member-name { + font-weight: 700; + color: var(--text-primary); + font-size: 1rem; +} + +.member-meta { + color: var(--text-secondary); + font-size: 0.82rem; +} + +.member-role { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + padding: 0.24rem 0.55rem; + border-radius: var(--border-radius-full); + width: fit-content; + text-transform: capitalize; + font-weight: 700; +} + +.member-role-owner { + background: rgba(245, 158, 11, 0.18); + color: #b45309; +} + +.member-role-admin { + background: rgba(30, 144, 255, 0.16); + color: var(--primary-dark); +} + +.member-role-member, +.member-role-viewer { + background: rgba(139, 92, 246, 0.12); + color: #6d28d9; +} + +.member-self-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.24rem 0.5rem; + border-radius: var(--border-radius-full); + background: rgba(245, 158, 11, 0.16); + color: #a16207; + font-size: 0.75rem; + font-weight: 700; +} + +.member-actions { + display: flex; + gap: 0.55rem; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + padding-top: 0.75rem; + border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent); +} + +.member-owner-action { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + +.member-role-action { + background: rgba(30, 144, 255, 0.14); + color: var(--primary-dark); + border-color: rgba(30, 144, 255, 0.34); +} + +.member-role-action:hover:not(:disabled) { + background: rgba(30, 144, 255, 0.22); + border-color: rgba(30, 144, 255, 0.54); + color: var(--primary-dark); +} + +[data-theme="dark"] .member-role-action, +body.dark-mode .member-role-action { + background: rgba(30, 144, 255, 0.22); + color: #d8ecff; + border-color: rgba(95, 178, 255, 0.4); +} + +[data-theme="dark"] .member-role-action:hover:not(:disabled), +body.dark-mode .member-role-action:hover:not(:disabled) { + background: rgba(30, 144, 255, 0.32); + border-color: rgba(95, 178, 255, 0.6); + color: #f3f9ff; +} + +[data-theme="dark"] .member-actions, +body.dark-mode .member-actions { + border-top-color: color-mix(in srgb, var(--color-border-medium) 88%, transparent); +} + +/* Danger Zone */ +.danger-zone { + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: + linear-gradient(180deg, rgba(254, 242, 242, 0.95), rgba(255, 255, 255, 0.78)), + var(--card-bg); +} + +[data-theme="dark"] .danger-zone, +body.dark-mode .danger-zone { + background: + linear-gradient(180deg, rgba(70, 26, 32, 0.92), rgba(28, 14, 19, 0.94)), + var(--card-bg); + border-color: color-mix(in srgb, var(--danger) 42%, transparent); +} + +.danger-zone h2, +.danger-zone .manage-section-eyebrow { + color: var(--danger); +} + +.danger-zone .manage-section-header { + align-items: center; +} + +/* Buttons */ +.btn-primary, +.btn-secondary, +.btn-danger { + min-height: 40px; + padding: 0.58rem 0.95rem; + border-radius: var(--border-radius-full); + font-size: 0.88rem; + font-weight: 600; +} + +.btn-small { + min-height: 34px; + padding: 0.38rem 0.72rem; + font-size: 0.8rem; +} + +.btn-danger:disabled { + background: var(--text-secondary); + opacity: 0.5; + cursor: not-allowed; +} + +/* Responsive */ +@media (max-width: 900px) { + .invite-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .invite-controls .btn-primary { + grid-column: 1 / -1; + } + + .invite-link-card { + grid-template-columns: 1fr; + } + + .invite-link-actions { + justify-content: flex-start; + } + + .pending-request-card { + grid-template-columns: 1fr; + } + + .pending-request-actions { + justify-content: flex-start; + } +} + +@media (max-width: 768px) { + .manage-section { + padding: 1rem; + } + + .manage-section-header, + .name-display, + .danger-zone .manage-section-header { + flex-direction: column; + align-items: stretch; + } + + .edit-name-form { + grid-template-columns: 1fr; + } + + .invite-controls { + grid-template-columns: 1fr; + } + + .invite-controls label, + .invite-controls select, + .invite-controls button { + width: 100%; + } + + .members-list { + grid-template-columns: 1fr; + } + + .member-actions { + width: 100%; + justify-content: flex-start; + } + + .member-actions button, + .pending-request-actions button { + flex: 1 1 100%; + } +} diff --git a/frontend/src/styles/components/manage/ManageStores.css b/frontend/src/styles/components/manage/ManageStores.css new file mode 100644 index 0000000..32275f1 --- /dev/null +++ b/frontend/src/styles/components/manage/ManageStores.css @@ -0,0 +1,175 @@ +/* Manage Stores Component */ +.manage-stores { + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 800px; + margin: 0 auto; + width: 100%; +} + +/* Section Styling */ +.manage-section { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; + width: 100%; + box-sizing: border-box; +} + +.manage-section h2 { + font-size: 1.3rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; +} + +.manage-stores-help { + margin: -0.25rem 0 1rem; + color: var(--text-secondary); +} + +.manage-stores-note { + margin: -0.25rem 0 1rem; + padding: 0.875rem 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--background); + color: var(--text-secondary); +} + +/* Stores List */ +.stores-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.store-card { + background: var(--background); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem; + transition: all 0.2s; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.store-card:hover { + border-color: var(--primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.store-info h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 0.5rem 0; +} + +.store-location { + color: var(--text-secondary); + font-size: 0.9rem; + margin: 0; +} + +.default-badge { + display: inline-block; + background: var(--primary); + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + margin-top: 0.5rem; +} + +.store-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Add Store Panel */ +.add-store-panel { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1rem; +} + +.available-stores { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.available-store-card { + background: var(--background); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + transition: all 0.2s; +} + +.available-store-card:hover { + border-color: var(--primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.available-store-card .store-info { + flex: 1; +} + +.available-store-card h3 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 0.25rem 0; +} + +.available-store-card .store-location { + color: var(--text-secondary); + font-size: 0.85rem; + margin: 0; +} + +/* Empty State */ +.empty-message { + color: var(--text-secondary); + text-align: center; + padding: 2rem; + font-style: italic; +} + +/* Responsive */ +@media (max-width: 600px) { + .stores-list, + .available-stores { + grid-template-columns: 1fr; + } + + .store-actions { + width: 100%; + } + + .store-actions button { + flex: 1; + } + + .available-store-card { + flex-direction: column; + align-items: flex-start; + } + + .available-store-card button { + width: 100%; + } +} diff --git a/frontend/src/styles/components/manage/StoreAvailableItemsManager.css b/frontend/src/styles/components/manage/StoreAvailableItemsManager.css new file mode 100644 index 0000000..42b870b --- /dev/null +++ b/frontend/src/styles/components/manage/StoreAvailableItemsManager.css @@ -0,0 +1,233 @@ +.store-available-items-trigger { + width: 100%; +} + +.store-items-modal-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + background: var(--modal-backdrop-bg); +} + +.store-items-modal { + width: min(960px, 100%); + max-height: min(80vh, 760px); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + padding: var(--spacing-lg); + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: var(--border-radius-xl); + background: var(--modal-bg); + box-shadow: var(--shadow-xl); +} + +.store-items-modal-header { + display: flex; + justify-content: space-between; + gap: var(--spacing-md); + align-items: flex-start; +} + +.store-items-modal-header h3 { + margin: 0; + color: var(--color-text-primary); + font-size: var(--font-size-xl); +} + +.store-items-modal-header p { + margin: var(--spacing-xs) 0 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.store-items-modal-close { + width: 40px; + height: 40px; + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: 50%; + background: var(--color-bg-surface); + color: var(--color-text-primary); + font-size: var(--font-size-lg); + line-height: 1; +} + +.store-items-modal-toolbar { + position: sticky; + top: 0; + z-index: 1; + background: var(--modal-bg); +} + +.store-available-items-search { + width: 100%; + padding: var(--input-padding-y) var(--input-padding-x); + border: var(--border-width-thin) solid var(--input-border-color); + border-radius: var(--input-border-radius); + background: var(--color-bg-surface); + color: var(--color-text-primary); +} + +.store-available-items-notice { + margin: 0; + padding: var(--spacing-sm) var(--spacing-md); + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: var(--border-radius-md); + background: var(--color-bg-surface); + color: var(--color-text-secondary); +} + +.store-items-modal-body { + min-height: 0; + overflow-y: auto; +} + +.store-items-table { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.store-items-table-head, +.store-items-table-row { + display: grid; + grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr); + gap: var(--spacing-md); + align-items: center; +} + +.store-items-table-head { + position: sticky; + top: 0; + padding: 0 var(--spacing-sm) var(--spacing-xs); + background: var(--modal-bg); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.store-items-table-body { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.store-items-table-row { + padding: var(--spacing-sm); + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: var(--border-radius-md); + background: var(--color-bg-surface); +} + +.store-items-table-cell { + min-width: 0; +} + +.store-items-table-item { + min-width: 0; +} + +.store-available-items-summary { + display: flex; + align-items: center; + gap: var(--spacing-sm); + min-width: 0; +} + +.store-available-items-thumb { + width: 48px; + height: 48px; + border-radius: var(--border-radius-md); + object-fit: cover; + background: var(--color-bg-muted); + flex-shrink: 0; +} + +.store-available-items-thumb-placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); + font-weight: var(--font-weight-semibold); +} + +.store-available-items-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.store-available-items-copy strong { + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.store-items-defaults-text { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.store-items-table-actions { + justify-self: end; +} + +.store-available-items-actions { + display: flex; + gap: var(--spacing-xs); + flex-wrap: wrap; + justify-content: flex-end; +} + +.store-items-mobile-label { + display: none; +} + +@media (max-width: 720px) { + .store-items-modal { + max-height: min(88vh, 900px); + padding: var(--spacing-md); + } + + .store-items-table-head { + display: none; + } + + .store-items-table-row { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--spacing-sm); + } + + .store-items-mobile-label { + display: block; + margin-bottom: 4px; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .store-items-table-actions { + justify-self: stretch; + } + + .store-available-items-actions { + width: 100%; + justify-content: stretch; + } + + .store-available-items-actions button { + flex: 1 1 0; + } +} diff --git a/frontend/src/styles/pages/AdminPanel.css b/frontend/src/styles/pages/AdminPanel.css index 0afe84b..237b9a6 100644 --- a/frontend/src/styles/pages/AdminPanel.css +++ b/frontend/src/styles/pages/AdminPanel.css @@ -1,9 +1,109 @@ -/* Admin Panel - uses utility classes */ -/* Responsive adjustments only */ +/* Admin Panel Layout */ +.admin-panel-body { + min-height: 100vh; + background: var(--background); + padding: 2rem 1rem; + display: flex; + justify-content: center; +} -@media (max-width: 768px) { - .admin-panel-page { - padding: var(--spacing-md) !important; +.admin-panel-container { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 0 1rem; +} + +.admin-panel-title { + font-size: 2rem; + font-weight: 600; + color: var(--text-primary); + text-align: center; + margin-bottom: 2rem; +} + +/* Tabs */ +.admin-tabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid var(--border); + margin-bottom: 2rem; +} + +.admin-tab { + padding: 0.75rem 1.5rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + color: var(--text-secondary); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.admin-tab:hover { + color: var(--text-primary); + background: var(--button-secondary-bg); +} + +.admin-tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +/* Content Area */ +.admin-content { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); } } +/* Users Section */ +.users-section { + max-width: 800px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .admin-panel-body { + padding: 1rem 0.75rem; + } + + .admin-panel-title { + font-size: 1.75rem; + } + + .admin-tab { + padding: 0.65rem 1rem; + font-size: 0.9rem; + } +} + +@media (max-width: 480px) { + .admin-panel-body { + padding: 1rem 0.5rem; + } + + .admin-panel-title { + font-size: 1.5rem; + } + + .admin-tab { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + } +} diff --git a/frontend/src/styles/pages/GroceryList.css b/frontend/src/styles/pages/GroceryList.css index 7a0b64b..cd0a15b 100644 --- a/frontend/src/styles/pages/GroceryList.css +++ b/frontend/src/styles/pages/GroceryList.css @@ -64,6 +64,31 @@ padding-top: var(--spacing-md); } +/* Empty State */ +.glist-empty-state { + margin: var(--spacing-xl) auto; + padding: var(--spacing-lg); + text-align: center; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + max-width: 32rem; + border: var(--border-width-thin) dashed var(--color-border-medium); + border-radius: var(--border-radius-lg); + background: var(--color-bg-surface); +} + +.glist-empty-title { + font-size: var(--font-size-lg); + color: var(--color-text-primary); + margin: 0; +} + +.glist-empty-text { + color: var(--color-text-secondary); + margin: 0; +} + .glist-section-header .glist-section-title { margin: 0; border: none; diff --git a/frontend/src/styles/pages/InviteLink.css b/frontend/src/styles/pages/InviteLink.css new file mode 100644 index 0000000..db27080 --- /dev/null +++ b/frontend/src/styles/pages/InviteLink.css @@ -0,0 +1,78 @@ +.invite-link-page { + min-height: calc(100vh - 80px); + display: flex; + justify-content: center; + align-items: center; + padding: 2rem 1rem; +} + +.invite-card { + width: 100%; + max-width: 540px; + border: 1px solid var(--border); + background: var(--card-bg); + border-radius: 12px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.invite-card h1 { + margin: 0; + font-size: 1.5rem; +} + +.invite-meta { + margin: 0; + color: var(--text-secondary); +} + +.invite-error { + margin: 0; + color: var(--danger); +} + +.invite-success { + margin: 0; + color: var(--success, #2e7d32); +} + +.invite-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.invite-btn { + border: none; + border-radius: 8px; + padding: 0.65rem 1rem; + background: var(--primary); + color: #fff; + text-decoration: none; + cursor: pointer; + font-weight: 600; +} + +.invite-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.invite-btn-secondary { + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border: 1px solid var(--button-secondary-border); +} + +@media (max-width: 640px) { + .invite-card { + padding: 1.1rem; + } + + .invite-btn { + width: 100%; + text-align: center; + } +} diff --git a/frontend/src/styles/pages/Login.css b/frontend/src/styles/pages/Login.css index 92aa35a..c044589 100644 --- a/frontend/src/styles/pages/Login.css +++ b/frontend/src/styles/pages/Login.css @@ -14,8 +14,8 @@ } .login-password-toggle { - background: #f0f0f0; - border: 1px solid #ccc; + background: var(--button-ghost-bg); + border: 1px solid var(--button-ghost-border); border-radius: 4px; cursor: pointer; font-size: 1.2em; @@ -31,6 +31,6 @@ .login-password-toggle:hover { opacity: 1; - background: #e8e8e8; + background: var(--button-ghost-hover-bg); } diff --git a/frontend/src/styles/pages/Manage.css b/frontend/src/styles/pages/Manage.css new file mode 100644 index 0000000..db15ade --- /dev/null +++ b/frontend/src/styles/pages/Manage.css @@ -0,0 +1,119 @@ +/* Manage Page Layout */ +.manage-body { + min-height: 100vh; + background: var(--background); + padding: 2rem 1rem; + display: flex; + justify-content: center; +} + +.manage-container { + max-width: 850px; + width: 100%; + margin: 0 auto; + padding: 0 1rem; +} + +.manage-title { + font-size: 2rem; + font-weight: 600; + color: var(--text-primary); + text-align: center; + margin-bottom: 2rem; +} + +/* Tabs */ +.manage-tabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid var(--border); + margin-bottom: 2rem; +} + +.manage-tab { + padding: 0.75rem 1.5rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + color: var(--text-secondary); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.manage-tab:hover { + color: var(--text-primary); + background: var(--button-secondary-bg); +} + +.manage-tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +/* Content Area */ +.manage-content { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Button Styles */ +.btn-primary, +.btn-secondary { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + background: var(--primary); + color: white; +} + +.btn-primary:hover, +.btn-secondary:hover { + background: var(--primary-dark, #0056b3); +} + +/* Responsive */ +@media (max-width: 768px) { + .manage-body { + padding: 1rem 0.75rem; + } + + .manage-title { + font-size: 1.75rem; + } + + .manage-tab { + padding: 0.65rem 1rem; + font-size: 0.9rem; + } +} + +@media (max-width: 480px) { + .manage-body { + padding: 1rem 0.5rem; + } + + .manage-title { + font-size: 1.5rem; + } + + .manage-tab { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + } +} diff --git a/frontend/src/styles/pages/Settings.css b/frontend/src/styles/pages/Settings.css index 6acd6a2..d2f2d7b 100644 --- a/frontend/src/styles/pages/Settings.css +++ b/frontend/src/styles/pages/Settings.css @@ -7,15 +7,34 @@ } /* Tabs */ +.settings-tabs-wrapper { + position: relative; + margin-bottom: var(--spacing-xl); + padding: 0 0.8rem; +} + .settings-tabs { display: flex; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-xl); + gap: 0rem; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; border-bottom: 2px solid var(--color-border-light); + touch-action: pan-x; /* Lock Y-axis, allow only horizontal scrolling */ + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ +} + +.settings-tabs::-webkit-scrollbar { + display: none; /* Chrome/Safari/Opera */ } .settings-tab { - padding: var(--spacing-md) var(--spacing-lg); + flex: 0 0 max-content; + white-space: nowrap; + width: max-content; + padding: 0rem 1.4rem; background: none; border: none; border-bottom: 3px solid transparent; @@ -27,9 +46,44 @@ margin-bottom: -2px; } +.settings-tabs-arrow { + position: absolute; + top: calc(50% - 0.2rem); + transform: translateY(-50%); + width: 2.6rem; + height: 2.6rem; + border-radius: 999px; + border: none; + background: transparent; + color: var(--color-primary); + font-size: 2rem; + font-weight: 700; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + z-index: 2; + opacity: 0; + pointer-events: none; + user-select: none; + transition: opacity 0.2s ease; +} + +.settings-tabs-arrow.visible { + opacity: 1; +} + +.settings-tabs-arrow-left { + left: -1.6rem; +} + +.settings-tabs-arrow-right { + right: -1.6rem; +} + .settings-tab:hover { color: var(--color-primary); - background: var(--color-bg-hover); + background: var(--button-secondary-bg); } .settings-tab.active { @@ -173,14 +227,30 @@ } .settings-tabs { - flex-wrap: nowrap; - overflow-x: auto; - -webkit-overflow-scrolling: touch; + padding: 0 0.1rem; } .settings-tab { - padding: var(--spacing-sm) var(--spacing-md); - white-space: nowrap; + padding: 0.4rem 0.35rem; + } + + .settings-tabs-wrapper { + padding: 0 0.55rem; + } + + .settings-tabs-arrow { + top: calc(50% - 0.15rem); + width: 2.2rem; + height: 2.2rem; + font-size: 1.65rem; + } + + .settings-tabs-arrow-left { + left: -1.2rem; + } + + .settings-tabs-arrow-right { + right: -1.2rem; } .settings-theme-options { diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index c9d7254..94e08ab 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -1,9 +1,9 @@ /** * Global Theme Variables - * + * * This file defines the design system for the entire application. * All colors, spacing, typography, and other design tokens are centralized here. - * + * * Usage: var(--variable-name) * Example: color: var(--color-primary); */ @@ -12,135 +12,139 @@ /* ============================================ COLOR PALETTE ============================================ */ - + /* Primary Colors */ - --color-primary: #007bff; - --color-primary-hover: #0056b3; - --color-primary-light: #e7f3ff; - --color-primary-dark: #0067d8; - + --color-primary: dodgerblue; + --color-primary-hover: #1677d2; + --color-primary-light: #dceeff; + --color-primary-dark: #0f5db4; + /* Secondary Colors */ - --color-secondary: #6c757d; - --color-secondary-hover: #545b62; - --color-secondary-light: #f8f9fa; - + --color-secondary: #7c5a3c; + --color-secondary-hover: #64462e; + --color-secondary-light: #f5ede4; + + /* Accent Colors */ + --color-accent: #f59e0b; + --color-accent-light: #fff2d8; + /* Semantic Colors */ - --color-success: #28a745; - --color-success-hover: #218838; - --color-success-light: #d4edda; - - --color-danger: #dc3545; - --color-danger-hover: #c82333; - --color-danger-light: #f8d7da; - - --color-warning: #ffc107; - --color-warning-hover: #e0a800; - --color-warning-light: #fff3cd; - - --color-info: #17a2b8; - --color-info-hover: #138496; - --color-info-light: #d1ecf1; - + --color-success: #15803d; + --color-success-hover: #166534; + --color-success-light: #dcfce7; + + --color-danger: #dc2626; + --color-danger-hover: #b91c1c; + --color-danger-light: #fee2e2; + + --color-warning: #d97706; + --color-warning-hover: #b45309; + --color-warning-light: #ffedd5; + + --color-info: #0369a1; + --color-info-hover: #075985; + --color-info-light: #dbeafe; + /* Neutral Colors */ --color-white: #ffffff; - --color-black: #000000; - --color-gray-50: #f9f9f9; - --color-gray-100: #f8f9fa; - --color-gray-200: #e9ecef; - --color-gray-300: #dee2e6; - --color-gray-400: #ced4da; - --color-gray-500: #adb5bd; - --color-gray-600: #6c757d; - --color-gray-700: #495057; - --color-gray-800: #343a40; - --color-gray-900: #212529; - + --color-black: #0f172a; + --color-gray-50: #fcfbf8; + --color-gray-100: #f6f3ee; + --color-gray-200: #ebe6dd; + --color-gray-300: #ddd4c7; + --color-gray-400: #b6ab9a; + --color-gray-500: #8e8579; + --color-gray-600: #6b645b; + --color-gray-700: #47423d; + --color-gray-800: #2d2a27; + --color-gray-900: #1c1917; + /* Text Colors */ - --color-text-primary: #212529; - --color-text-secondary: #6c757d; - --color-text-muted: #adb5bd; - --color-text-inverse: #ffffff; - --color-text-disabled: #6c757d; - + --color-text-primary: #1f2937; + --color-text-secondary: #5b6473; + --color-text-muted: #8e98a8; + --color-text-inverse: #f8fafc; + --color-text-disabled: #9aa4b2; + /* Background Colors */ - --color-bg-body: #f8f9fa; - --color-bg-surface: #ffffff; - --color-bg-hover: #f5f5f5; - --color-bg-disabled: #e9ecef; - + --color-bg-body: #f4f1ea; + --color-bg-surface: rgba(255, 255, 255, 0.9); + --color-bg-elevated: rgba(255, 255, 255, 0.98); + --color-bg-hover: #f2f7f6; + --color-bg-disabled: #ece7de; + --color-bg-hero: linear-gradient(135deg, rgba(15, 118, 110, 0.12), rgba(245, 158, 11, 0.12)); + /* Border Colors */ - --color-border-light: #e0e0e0; - --color-border-medium: #ccc; - --color-border-dark: #999; - --color-border-disabled: #dee2e6; - + --color-border-light: rgba(119, 107, 91, 0.18); + --color-border-medium: rgba(119, 107, 91, 0.32); + --color-border-dark: rgba(91, 81, 69, 0.55); + --color-border-disabled: rgba(148, 163, 184, 0.35); + /* ============================================ SPACING ============================================ */ - --spacing-xs: 0.25rem; /* 4px */ - --spacing-sm: 0.5rem; /* 8px */ - --spacing-md: 1rem; /* 16px */ - --spacing-lg: 1.5rem; /* 24px */ - --spacing-xl: 2rem; /* 32px */ - --spacing-2xl: 3rem; /* 48px */ - --spacing-3xl: 4rem; /* 64px */ - + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + --spacing-3xl: 4rem; + /* ============================================ TYPOGRAPHY ============================================ */ - --font-family-base: Arial, sans-serif; - --font-family-heading: Arial, sans-serif; - --font-family-mono: 'Courier New', monospace; - - /* Font Sizes */ - --font-size-xs: 0.75rem; /* 12px */ - --font-size-sm: 0.875rem; /* 14px */ - --font-size-base: 1rem; /* 16px */ - --font-size-lg: 1.125rem; /* 18px */ - --font-size-xl: 1.25rem; /* 20px */ - --font-size-2xl: 1.5rem; /* 24px */ - --font-size-3xl: 2rem; /* 32px */ - - /* Font Weights */ + --font-family-base: "Aptos", "Segoe UI Variable Text", "Segoe UI", sans-serif; + --font-family-heading: "Aptos Display", "Aptos", "Segoe UI Variable Display", "Segoe UI", sans-serif; + --font-family-mono: "IBM Plex Mono", "Cascadia Code", "Consolas", monospace; + + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 2rem; + --font-size-4xl: 2.75rem; + --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; - - /* Line Heights */ - --line-height-tight: 1.2; + + --line-height-tight: 1.15; --line-height-normal: 1.5; --line-height-relaxed: 1.75; - + /* ============================================ BORDERS & RADIUS ============================================ */ --border-width-thin: 1px; --border-width-medium: 2px; --border-width-thick: 4px; - - --border-radius-sm: 4px; - --border-radius-md: 6px; - --border-radius-lg: 8px; - --border-radius-xl: 12px; - --border-radius-full: 50%; - + + --border-radius-sm: 10px; + --border-radius-md: 14px; + --border-radius-lg: 20px; + --border-radius-xl: 28px; + --border-radius-full: 999px; + /* ============================================ SHADOWS ============================================ */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); - --shadow-card: 0 0 10px rgba(0, 0, 0, 0.08); - + --shadow-sm: 0 8px 20px rgba(36, 33, 28, 0.06); + --shadow-md: 0 16px 34px rgba(36, 33, 28, 0.1); + --shadow-lg: 0 24px 56px rgba(36, 33, 28, 0.14); + --shadow-xl: 0 32px 80px rgba(36, 33, 28, 0.18); + --shadow-card: 0 14px 36px rgba(36, 33, 28, 0.09); + /* ============================================ TRANSITIONS ============================================ */ --transition-fast: 0.15s ease; - --transition-base: 0.2s ease; - --transition-slow: 0.3s ease; - + --transition-base: 0.24s ease; + --transition-slow: 0.35s ease; + /* ============================================ Z-INDEX LAYERS ============================================ */ @@ -150,147 +154,167 @@ --z-modal-backdrop: 900; --z-modal: 1000; --z-tooltip: 1100; - + /* ============================================ LAYOUT ============================================ */ - --container-max-width: 480px; + --container-max-width: 560px; + --page-max-width: 1180px; --container-padding: var(--spacing-md); - + /* ============================================ COMPONENT-SPECIFIC ============================================ */ - - /* Buttons */ - --button-padding-y: 0.6rem; - --button-padding-x: 1.5rem; - --button-border-radius: var(--border-radius-sm); - --button-font-weight: var(--font-weight-medium); - - /* Inputs */ - --input-padding-y: 0.6rem; - --input-padding-x: 0.75rem; - --input-border-color: var(--color-border-medium); - --input-border-radius: var(--border-radius-sm); + --button-padding-y: 0.8rem; + --button-padding-x: 1.25rem; + --button-border-radius: var(--border-radius-full); + --button-font-weight: var(--font-weight-semibold); + + --input-padding-y: 0.85rem; + --input-padding-x: 1rem; + --input-border-color: var(--color-border-light); + --input-border-radius: var(--border-radius-md); --input-focus-border-color: var(--color-primary); - --input-focus-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); - - /* Cards */ + --input-focus-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12); + --card-bg: var(--color-bg-surface); - --card-padding: var(--spacing-md); + --card-padding: var(--spacing-lg); --card-border-radius: var(--border-radius-lg); --card-shadow: var(--shadow-card); - - /* Modals */ - --modal-backdrop-bg: rgba(0, 0, 0, 0.5); - --modal-bg: var(--color-white); + + --button-secondary-bg: rgba(30, 144, 255, 0.12); + --button-secondary-hover-bg: rgba(30, 144, 255, 0.2); + --button-secondary-border: rgba(30, 144, 255, 0.26); + --button-secondary-border-hover: rgba(30, 144, 255, 0.42); + --button-secondary-text: var(--color-primary-dark); + + --button-ghost-bg: rgba(30, 144, 255, 0.08); + --button-ghost-hover-bg: rgba(30, 144, 255, 0.16); + --button-ghost-border: rgba(30, 144, 255, 0.22); + --button-ghost-border-hover: rgba(30, 144, 255, 0.38); + --button-ghost-text: var(--color-primary-dark); + + --modal-backdrop-bg: rgba(15, 23, 42, 0.48); + --modal-bg: var(--color-bg-elevated); --modal-border-radius: var(--border-radius-lg); --modal-padding: var(--spacing-lg); --modal-max-width: 500px; + + /* ============================================ + SIMPLIFIED ALIASES + ============================================ */ + --primary: var(--color-primary); + --primary-dark: var(--color-primary-dark); + --primary-light: var(--color-primary-light); + --danger: var(--color-danger); + --danger-dark: var(--color-danger-hover); + --success: var(--color-success); + --success-light: var(--color-success-light); + --text-primary: var(--color-text-primary); + --text-secondary: var(--color-text-secondary); + --background: var(--color-bg-body); + --border: var(--color-border-light); + --card-hover: var(--color-bg-hover); } - -/* ============================================ - DARK MODE - ============================================ */ [data-theme="dark"] { - /* Primary Colors */ - --color-primary: #4da3ff; - --color-primary-hover: #66b3ff; - --color-primary-light: #1a3a52; - --color-primary-dark: #3d8fdb; + --color-primary: #5fb2ff; + --color-primary-hover: #83c4ff; + --color-primary-light: rgba(95, 178, 255, 0.14); + --color-primary-dark: #2d8ff0; + + --color-secondary: #f4c27a; + --color-secondary-hover: #ffd59e; + --color-secondary-light: rgba(244, 194, 122, 0.12); + --color-accent: #fbbf24; + --color-accent-light: rgba(251, 191, 36, 0.16); - /* Semantic Colors */ --color-success: #4ade80; - --color-success-hover: #5fe88d; - --color-success-light: #1a3a28; + --color-success-hover: #86efac; + --color-success-light: rgba(74, 222, 128, 0.16); --color-danger: #f87171; - --color-danger-hover: #fa8585; - --color-danger-light: #4a2020; + --color-danger-hover: #fca5a5; + --color-danger-light: rgba(248, 113, 113, 0.16); --color-warning: #fbbf24; --color-warning-hover: #fcd34d; - --color-warning-light: #3a2f0f; + --color-warning-light: rgba(251, 191, 36, 0.16); --color-info: #38bdf8; - --color-info-hover: #5dc9fc; - --color-info-light: #1a2f3a; + --color-info-hover: #7dd3fc; + --color-info-light: rgba(56, 189, 248, 0.16); - /* Text Colors */ - --color-text-primary: #f1f5f9; - --color-text-secondary: #94a3b8; - --color-text-muted: #64748b; - --color-text-inverse: #1e293b; - --color-text-disabled: #475569; + --color-text-primary: #f4f7fb; + --color-text-secondary: #b2bccb; + --color-text-muted: #7f8aa0; + --color-text-inverse: #0f172a; + --color-text-disabled: #667085; - /* Background Colors */ - --color-bg-body: #0f172a; - --color-bg-surface: #1e293b; - --color-bg-hover: #334155; - --color-bg-disabled: #1e293b; + --color-bg-body: #0f1722; + --color-bg-surface: rgba(15, 23, 34, 0.84); + --color-bg-elevated: rgba(20, 29, 42, 0.96); + --color-bg-hover: rgba(30, 41, 59, 0.95); + --color-bg-disabled: rgba(30, 41, 59, 0.7); + --color-bg-hero: linear-gradient(135deg, rgba(45, 212, 191, 0.18), rgba(251, 191, 36, 0.16)); - /* Border Colors */ - --color-border-light: #334155; - --color-border-medium: #475569; - --color-border-dark: #64748b; - --color-border-disabled: #334155; + --color-border-light: rgba(148, 163, 184, 0.18); + --color-border-medium: rgba(148, 163, 184, 0.32); + --color-border-dark: rgba(203, 213, 225, 0.48); + --color-border-disabled: rgba(100, 116, 139, 0.3); - /* Neutral Colors - Dark adjusted */ - --color-gray-50: #1e293b; - --color-gray-100: #1e293b; - --color-gray-200: #334155; - --color-gray-300: #475569; - --color-gray-400: #64748b; - --color-gray-500: #94a3b8; - --color-gray-600: #cbd5e1; - --color-gray-700: #e2e8f0; - --color-gray-800: #f1f5f9; + --color-gray-50: #111827; + --color-gray-100: #172030; + --color-gray-200: #1f2937; + --color-gray-300: #334155; + --color-gray-400: #475569; + --color-gray-500: #64748b; + --color-gray-600: #94a3b8; + --color-gray-700: #cbd5e1; + --color-gray-800: #e2e8f0; --color-gray-900: #f8fafc; - /* Shadows - Lighter for dark mode */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6); - --shadow-card: 0 0 10px rgba(0, 0, 0, 0.5); + --shadow-sm: 0 10px 24px rgba(2, 6, 23, 0.24); + --shadow-md: 0 18px 40px rgba(2, 6, 23, 0.34); + --shadow-lg: 0 28px 60px rgba(2, 6, 23, 0.42); + --shadow-xl: 0 42px 90px rgba(2, 6, 23, 0.5); + --shadow-card: 0 18px 44px rgba(2, 6, 23, 0.34); - /* Modals */ - --modal-backdrop-bg: rgba(0, 0, 0, 0.8); - --modal-bg: var(--color-bg-surface); - - /* Inputs */ - --input-border-color: var(--color-border-medium); - --input-focus-shadow: 0 0 0 2px rgba(77, 163, 255, 0.3); - - /* Cards */ + --modal-backdrop-bg: rgba(2, 6, 23, 0.72); + --modal-bg: var(--color-bg-elevated); + --input-focus-shadow: 0 0 0 4px rgba(45, 212, 191, 0.18); --card-bg: var(--color-bg-surface); + + --button-secondary-bg: rgba(95, 178, 255, 0.18); + --button-secondary-hover-bg: rgba(95, 178, 255, 0.28); + --button-secondary-border: rgba(95, 178, 255, 0.3); + --button-secondary-border-hover: rgba(131, 196, 255, 0.5); + --button-secondary-text: #d8ecff; + + --button-ghost-bg: rgba(95, 178, 255, 0.12); + --button-ghost-hover-bg: rgba(95, 178, 255, 0.22); + --button-ghost-border: rgba(95, 178, 255, 0.24); + --button-ghost-border-hover: rgba(131, 196, 255, 0.4); + --button-ghost-text: #d8ecff; } - -/* ============================================ - DARK MODE SUPPORT (Future Implementation) - ============================================ */ @media (prefers-color-scheme: dark) { /* Auto mode will use data-theme attribute set by JS */ } - -/* Manual dark mode class override (deprecated - use data-theme) */ .dark-mode { - --color-text-primary: #f8f9fa; - --color-text-secondary: #adb5bd; - --color-bg-body: #212529; - --color-bg-surface: #343a40; - --color-border-light: #495057; - --color-border-medium: #6c757d; + --color-text-primary: #f4f7fb; + --color-text-secondary: #b2bccb; + --color-bg-body: #0f1722; + --color-bg-surface: rgba(15, 23, 34, 0.84); + --color-border-light: rgba(148, 163, 184, 0.18); + --color-border-medium: rgba(148, 163, 184, 0.32); } /* ============================================ UTILITY CLASSES ============================================ */ -/* Spacing Utilities */ .m-0 { margin: 0 !important; } .mt-1 { margin-top: var(--spacing-xs) !important; } .mt-2 { margin-top: var(--spacing-sm) !important; } @@ -308,7 +332,6 @@ .p-3 { padding: var(--spacing-md) !important; } .p-4 { padding: var(--spacing-lg) !important; } -/* Text Utilities */ .text-center { text-align: center !important; } .text-left { text-align: left !important; } .text-right { text-align: right !important; } @@ -324,13 +347,11 @@ .font-weight-semibold { font-weight: var(--font-weight-semibold) !important; } .font-weight-bold { font-weight: var(--font-weight-bold) !important; } -/* Display Utilities */ .d-none { display: none !important; } .d-block { display: block !important; } .d-flex { display: flex !important; } .d-inline-block { display: inline-block !important; } -/* Flex Utilities */ .flex-column { flex-direction: column !important; } .flex-row { flex-direction: row !important; } .justify-center { justify-content: center !important; } diff --git a/frontend/src/styles/utilities.css b/frontend/src/styles/utilities.css index e02a4e4..91d8608 100644 --- a/frontend/src/styles/utilities.css +++ b/frontend/src/styles/utilities.css @@ -61,17 +61,21 @@ ============================================ */ .card { - background: var(--color-bg-surface); + background: var(--card-bg); + border: 1px solid var(--color-border-light); border-radius: var(--card-border-radius); padding: var(--card-padding); box-shadow: var(--shadow-card); + backdrop-filter: blur(14px); } .card-elevated { - background: var(--color-bg-surface); - border-radius: var(--card-border-radius); - padding: var(--spacing-lg); - box-shadow: var(--shadow-lg); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-light); + border-radius: var(--border-radius-xl); + padding: clamp(1.5rem, 3vw, 2.25rem); + box-shadow: var(--shadow-xl); + backdrop-filter: blur(18px); } .card-title { @@ -87,74 +91,83 @@ .btn { padding: var(--button-padding-y) var(--button-padding-x); - border: none; + border: 1px solid transparent; border-radius: var(--button-border-radius); font-size: var(--font-size-base); font-weight: var(--button-font-weight); cursor: pointer; - transition: var(--transition-base); + transition: transform var(--transition-base), box-shadow var(--transition-base), background var(--transition-base), color var(--transition-base), border-color var(--transition-base); text-align: center; - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + min-height: 46px; } .btn-primary { - background: var(--color-primary); + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark)); color: var(--color-text-inverse); + box-shadow: 0 14px 30px rgba(30, 144, 255, 0.22); } .btn-primary:hover:not(:disabled) { - background: var(--color-primary-hover); - transform: translateY(-1px); - box-shadow: var(--shadow-md); + transform: translateY(-2px); + box-shadow: 0 18px 34px rgba(30, 144, 255, 0.28); } .btn-secondary { - background: var(--color-secondary); - color: var(--color-text-inverse); + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border-color: var(--button-secondary-border); } .btn-secondary:hover:not(:disabled) { - background: var(--color-secondary-hover); + background: var(--button-secondary-hover-bg); + border-color: var(--button-secondary-border-hover); + transform: translateY(-1px); } .btn-danger { - background: var(--color-danger); + background: linear-gradient(135deg, var(--color-danger), var(--color-danger-hover)); color: var(--color-text-inverse); } .btn-danger:hover:not(:disabled) { - background: var(--color-danger-hover); + transform: translateY(-1px); + box-shadow: 0 16px 30px rgba(220, 38, 38, 0.18); } .btn-success { - background: var(--color-success); + background: linear-gradient(135deg, var(--color-success), var(--color-success-hover)); color: var(--color-text-inverse); } .btn-success:hover:not(:disabled) { - background: var(--color-success-hover); + transform: translateY(-1px); + box-shadow: 0 16px 30px rgba(21, 128, 61, 0.18); } .btn-outline { - background: transparent; - color: var(--color-primary); + background: var(--button-ghost-bg); + color: var(--button-ghost-text); border: var(--border-width-thin) solid var(--color-primary); } .btn-outline:hover:not(:disabled) { - background: var(--color-primary); - color: var(--color-text-inverse); + background: var(--button-ghost-hover-bg); + transform: translateY(-1px); } .btn-ghost { - background: var(--color-bg-surface); - color: var(--color-text-primary); - border: var(--border-width-thin) solid var(--color-border-medium); + background: var(--button-ghost-bg); + color: var(--button-ghost-text); + border: var(--border-width-thin) solid var(--button-ghost-border); } .btn-ghost:hover:not(:disabled) { - background: var(--color-bg-hover); - border-color: var(--color-border-dark); + background: var(--button-ghost-hover-bg); + border-color: var(--button-ghost-border-hover); } .btn-sm { @@ -175,6 +188,8 @@ .btn:disabled { opacity: 0.6; cursor: not-allowed; + box-shadow: none; + transform: none; } /* ============================================ @@ -200,8 +215,8 @@ border-radius: var(--input-border-radius); font-size: var(--font-size-base); color: var(--color-text-primary); - background: var(--color-bg-surface); - transition: var(--transition-base); + background: rgba(255, 255, 255, 0.72); + transition: border-color var(--transition-base), box-shadow var(--transition-base), background var(--transition-base), transform var(--transition-base); box-sizing: border-box; } @@ -209,6 +224,8 @@ outline: none; border-color: var(--input-focus-border-color); box-shadow: var(--input-focus-shadow); + background: var(--color-bg-elevated); + transform: translateY(-1px); } .form-input::placeholder { @@ -222,7 +239,7 @@ border-radius: var(--input-border-radius); font-size: var(--font-size-base); color: var(--color-text-primary); - background: var(--color-bg-surface); + background: rgba(255, 255, 255, 0.72); cursor: pointer; transition: var(--transition-base); } @@ -231,6 +248,7 @@ outline: none; border-color: var(--input-focus-border-color); box-shadow: var(--input-focus-shadow); + background: var(--color-bg-elevated); } /* ============================================ diff --git a/frontend/tests/auth-smoke.spec.ts b/frontend/tests/auth-smoke.spec.ts new file mode 100644 index 0000000..932bccc --- /dev/null +++ b/frontend/tests/auth-smoke.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from "@playwright/test"; + +test("redirects unauthenticated users to login", async ({ page }) => { + await page.goto("/"); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole("heading", { name: "Login" })).toBeVisible(); + await expect(page.getByPlaceholder("Username")).toBeVisible(); + await expect(page.getByPlaceholder("Password")).toBeVisible(); +}); diff --git a/frontend/tests/available-items-catalog.spec.ts b/frontend/tests/available-items-catalog.spec.ts new file mode 100644 index 0000000..071d12c --- /dev/null +++ b/frontend/tests/available-items-catalog.spec.ts @@ -0,0 +1,195 @@ +import { expect, test } from "@playwright/test"; +import { + confirmSlide, + mockConfig, + mockHouseholdAndStoreShell, + seedAuthStorage, +} from "./helpers/e2e"; + +test("manage stores opens a modal to edit and delete household store items", async ({ page }) => { + await seedAuthStorage(page, { username: "catalog-user" }); + await mockConfig(page); + await mockHouseholdAndStoreShell(page, { + household: { name: "Catalog House" }, + }); + + let availableItems = [ + { + item_id: 501, + item_name: "milk", + item_image: null, + image_mime_type: null, + item_type: "dairy", + item_group: "Milk", + zone: "Dairy & Refrigerated", + has_managed_settings: true, + }, + { + item_id: 777, + item_name: "apples", + item_image: null, + image_mime_type: null, + item_type: null, + item_group: null, + zone: null, + has_managed_settings: false, + }, + ]; + + await page.route("**/stores", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: 10, name: "Costco" }]), + }); + }); + + await page.route("**/households/1/stores/10/available-items*", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const query = (url.searchParams.get("query") || "").toLowerCase(); + + if (request.method() === "GET") { + const filteredItems = availableItems.filter((item) => item.item_name.includes(query)); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: filteredItems, catalog_ready: true }), + }); + return; + } + + await route.fulfill({ status: 500 }); + }); + + await page.route("**/households/1/stores/10/available-items/777", async (route) => { + if (route.request().method() === "PATCH") { + availableItems = availableItems.map((item) => + item.item_id === 777 + ? { + ...item, + item_type: "produce", + item_group: "Fruits", + zone: "Produce & Fresh Vegetables", + has_managed_settings: true, + } + : item + ); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: "Available item updated", + item: availableItems.find((item) => item.item_id === 777), + }), + }); + return; + } + + await route.fulfill({ status: 500 }); + }); + + await page.route("**/households/1/stores/10/available-items/501", async (route) => { + if (route.request().method() === "DELETE") { + availableItems = availableItems.filter((item) => item.item_id !== 501); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ message: "Store item deleted" }), + }); + return; + } + + await route.fulfill({ status: 500 }); + }); + + await page.goto("/manage?tab=stores"); + + const storeCard = page.locator(".store-card").filter({ hasText: "Costco" }); + await expect(storeCard).toBeVisible(); + await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible(); + + await storeCard.getByRole("button", { name: "Manage Items" }).click(); + + const managerModal = page.locator(".store-items-modal"); + await expect(managerModal).toBeVisible(); + await expect(managerModal.getByText("milk", { exact: true })).toBeVisible(); + await expect(managerModal.getByText("apples", { exact: true })).toBeVisible(); + + await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click(); + const editorModal = page.locator(".available-item-editor-modal"); + await expect(editorModal).toBeVisible(); + await expect(editorModal.getByLabel("Item Name")).toBeDisabled(); + await editorModal.locator(".available-item-editor-select").nth(0).selectOption("produce"); + await editorModal.locator(".available-item-editor-select").nth(1).selectOption("Fruits"); + await editorModal.locator(".available-item-editor-select").nth(2).selectOption("Produce & Fresh Vegetables"); + await editorModal.getByRole("button", { name: "Save Changes" }).click(); + + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item"); + await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible(); + + await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click(); + const confirmModal = page.locator(".confirm-slide-modal"); + await expect(confirmModal).toBeVisible(); + await expect(confirmModal.getByText("Delete milk?")).toBeVisible(); + + await confirmSlide(page); + + await expect( + page.locator(".action-toast.action-toast-success").filter({ hasText: "Deleted store item" }) + ).toContainText("Deleted store item"); + await expect(managerModal.locator(".store-items-table-row").filter({ hasText: "milk" })).toHaveCount(0); +}); + +test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + await mockHouseholdAndStoreShell(page); + + await page.route("**/households/1/members", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" }, + ]), + }); + }); + + await page.route("**/households/1/stores/10/list/recent", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/stores/10/list/suggestions**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/stores/10/list/item**", async (route) => { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ message: "Item not found" }), + }); + }); + + await page.route("**/households/1/stores/10/list", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [] }), + }); + }); + + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Store Items" })).toHaveCount(0); + await expect(page.locator(".available-items-picker-modal")).toHaveCount(0); +}); diff --git a/frontend/tests/buy-modal-auto-advance.spec.ts b/frontend/tests/buy-modal-auto-advance.spec.ts new file mode 100644 index 0000000..7bfb7bc --- /dev/null +++ b/frontend/tests/buy-modal-auto-advance.spec.ts @@ -0,0 +1,246 @@ +import { expect, test } from "@playwright/test"; +import { + mockConfig, + mockHouseholdAndStoreShell, + seedAuthStorage, +} from "./helpers/e2e"; + +type MockItem = { + id: number; + item_id: number; + item_name: string; + quantity: number; + bought: boolean; + item_image: string | null; + image_mime_type: string | null; + added_by_users: string[]; + last_added_on: string; + item_type: string | null; + item_group: string | null; + zone: string | null; +}; + +function makeItem( + id: number, + itemName: string, + quantity: number, + overrides: Partial = {} +): MockItem { + return { + id, + item_id: id + 500, + item_name: itemName, + quantity, + bought: false, + item_image: null, + image_mime_type: null, + added_by_users: ["Owner User"], + last_added_on: "2026-03-28T12:00:00.000Z", + item_type: null, + item_group: null, + zone: "Produce", + ...overrides, + }; +} + +async function setupBuyModalRoutes( + page: import("@playwright/test").Page, + initialItems: MockItem[] +) { + let activeItems = initialItems.map((item) => ({ ...item })); + let recentItems: MockItem[] = []; + + await mockHouseholdAndStoreShell(page, { + household: { name: "Auto Advance House" }, + }); + + await page.route("**/households/1/members", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" }, + ]), + }); + }); + + await page.route("**/households/1/stores/10/list/recent", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(recentItems), + }); + }); + + await page.route("**/households/1/stores/10/list/suggestions**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/stores/10/list/classification**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ classification: null }), + }); + }); + + await page.route("**/households/1/stores/10/list/item**", async (route) => { + const request = route.request(); + + if (request.method() === "PATCH") { + const body = request.postDataJSON() as { + item_name?: string; + quantity_bought?: number | null; + }; + const itemName = String(body.item_name || "").toLowerCase(); + const quantityBought = Number(body.quantity_bought ?? 0); + const currentItem = activeItems.find((item) => item.item_name === itemName); + + if (!currentItem) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: { message: "Item not found" } }), + }); + return; + } + + const remainingQuantity = currentItem.quantity - quantityBought; + recentItems = [ + { + ...currentItem, + quantity: quantityBought, + bought: true, + }, + ...recentItems, + ]; + + if (remainingQuantity <= 0) { + activeItems = activeItems.filter((item) => item.id !== currentItem.id); + } else { + activeItems = activeItems.map((item) => + item.id === currentItem.id + ? { ...item, quantity: remainingQuantity } + : item + ); + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: "Item updated", + item: { + id: currentItem.id, + item_name: currentItem.item_name, + quantity: Math.max(remainingQuantity, 0), + bought: remainingQuantity <= 0, + }, + }), + }); + return; + } + + const url = new URL(request.url()); + const itemName = (url.searchParams.get("item_name") || "").toLowerCase(); + const item = activeItems.find((entry) => entry.item_name === itemName); + + await route.fulfill({ + status: item ? 200 : 404, + contentType: "application/json", + body: JSON.stringify(item || { message: "Item not found" }), + }); + }); + + await page.route("**/households/1/stores/10/list", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: activeItems, + }), + }); + }); +} + +async function openBuyModal(page: import("@playwright/test").Page, itemName: string) { + const row = page.locator(".glist-li").filter({ hasText: itemName }); + await row.click(); + await expect(page.locator(".confirm-buy-modal")).toBeVisible(); +} + +test("buying an item advances to the next one in the current sort order", async ({ page }) => { + await seedAuthStorage(page, { username: "buy-modal-user" }); + await mockConfig(page); + await setupBuyModalRoutes(page, [ + makeItem(1, "milk", 2), + makeItem(2, "bread", 5), + makeItem(3, "apples", 3), + ]); + + await page.goto("/"); + await page.locator(".glist-sort").selectOption("qty-high"); + + await openBuyModal(page, "bread"); + await page.getByRole("button", { name: "Mark as Bought" }).click(); + + await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples"); +}); + +test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => { + await seedAuthStorage(page, { username: "buy-modal-user" }); + await mockConfig(page); + await setupBuyModalRoutes(page, [ + makeItem(1, "apples", 3), + makeItem(2, "bread", 5), + makeItem(3, "milk", 2), + ]); + + await page.goto("/"); + await page.locator(".glist-sort").selectOption("az"); + + await openBuyModal(page, "milk"); + await page.getByRole("button", { name: "Mark as Bought" }).click(); + + await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples"); +}); + +test("partial buy keeps the item on the list and advances past it", async ({ page }) => { + await seedAuthStorage(page, { username: "buy-modal-user" }); + await mockConfig(page); + await setupBuyModalRoutes(page, [ + makeItem(1, "alpha", 1), + makeItem(2, "bravo", 3), + makeItem(3, "charlie", 5), + ]); + + await page.goto("/"); + await page.locator(".glist-sort").selectOption("qty-low"); + + await openBuyModal(page, "bravo"); + await page.locator(".confirm-buy-counter-btn").nth(0).click(); + await page.locator(".confirm-buy-counter-btn").nth(0).click(); + await page.getByRole("button", { name: "Mark as Bought" }).click(); + + await expect(page.locator(".confirm-buy-item-name")).toHaveText("charlie"); + await expect(page.locator(".glist-li").filter({ hasText: "bravo" }).first()).toContainText("x2"); +}); + +test("buying the only remaining item closes the modal", async ({ page }) => { + await seedAuthStorage(page, { username: "buy-modal-user" }); + await mockConfig(page); + await setupBuyModalRoutes(page, [ + makeItem(1, "solo", 1), + ]); + + await page.goto("/"); + + await openBuyModal(page, "solo"); + await page.getByRole("button", { name: "Mark as Bought" }).click(); + + await expect(page.locator(".confirm-buy-modal")).toBeHidden(); +}); diff --git a/frontend/tests/classification-details.spec.ts b/frontend/tests/classification-details.spec.ts new file mode 100644 index 0000000..3f49a7c --- /dev/null +++ b/frontend/tests/classification-details.spec.ts @@ -0,0 +1,322 @@ +import { expect, test } from "@playwright/test"; + +function seedAuthStorage(page: import("@playwright/test").Page) { + return page.addInitScript(() => { + localStorage.setItem("token", "test-token"); + localStorage.setItem("userId", "1"); + localStorage.setItem("role", "admin"); + localStorage.setItem("username", "classification-user"); + }); +} + +async function mockConfig(page: import("@playwright/test").Page) { + await page.route("**/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + maxFileSizeMB: 20, + maxImageDimension: 800, + imageQuality: 85, + }), + }); + }); +} + +async function setupGroceryListRoutes(page: import("@playwright/test").Page) { + let currentItem: { + id: number; + item_id: number; + item_name: string; + quantity: number; + bought: boolean; + item_image: string | null; + image_mime_type: string | null; + added_by_users: string[]; + last_added_on: string; + item_type: string | null; + item_group: string | null; + zone: string | null; + } | null = null; + let currentClassification: { + item_type: string | null; + item_group: string | null; + zone: string | null; + } | null = null; + let classificationRequestMode: "success" | "error" = "success"; + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: 1, name: "Classification House", role: "admin", invite_code: "ABCD1234" }, + ]), + }); + }); + + await page.route("**/stores/household/1", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: 10, name: "Costco", location: "Warehouse", is_default: true }, + ]), + }); + }); + + await page.route("**/households/1/members", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" }, + ]), + }); + }); + + await page.route("**/households/1/stores/10/list/recent", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/stores/10/list/suggestions**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/stores/10/list/classification**", async (route) => { + const request = route.request(); + + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ classification: currentClassification }), + }); + return; + } + + const body = request.postDataJSON() as { + classification?: string | { item_type?: string | null; item_group?: string | null; zone?: string | null }; + }; + + if (classificationRequestMode === "error") { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: { message: "Invalid zone" }, + }), + }); + return; + } + + const payload = typeof body.classification === "string" + ? { item_type: body.classification, item_group: null, zone: null } + : { + item_type: body.classification?.item_type ?? null, + item_group: body.classification?.item_group ?? null, + zone: body.classification?.zone ?? null, + }; + + currentClassification = payload; + if (currentItem) { + currentItem = { + ...currentItem, + item_type: payload.item_type, + item_group: payload.item_group, + zone: payload.zone, + }; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: "Classification set", + classification: payload, + }), + }); + }); + + await page.route("**/households/1/stores/10/list/item**", async (route) => { + const request = route.request(); + + if (request.method() === "PUT") { + const body = request.postDataJSON() as { item_name?: string; quantity?: number }; + if (currentItem) { + currentItem = { + ...currentItem, + item_name: String(body.item_name || currentItem.item_name).toLowerCase(), + quantity: Number(body.quantity || currentItem.quantity), + }; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: "Item updated", + item: { + id: currentItem?.id || 201, + item_name: currentItem?.item_name || "yogurt", + quantity: currentItem?.quantity || 1, + }, + }), + }); + return; + } + + const url = new URL(request.url()); + const itemName = (url.searchParams.get("item_name") || "").toLowerCase(); + const itemMatches = currentItem && currentItem.item_name === itemName; + + await route.fulfill({ + status: itemMatches ? 200 : 404, + contentType: "application/json", + body: JSON.stringify(itemMatches ? currentItem : { message: "Item not found" }), + }); + }); + + await page.route("**/households/1/stores/10/list/add", async (route) => { + currentItem = { + id: 201, + item_id: 501, + item_name: "yogurt", + quantity: 1, + bought: false, + item_image: null, + image_mime_type: null, + added_by_users: ["Owner User"], + last_added_on: "2026-03-28T12:00:00.000Z", + item_type: currentClassification?.item_type ?? null, + item_group: currentClassification?.item_group ?? null, + zone: currentClassification?.zone ?? null, + }; + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: "Item added", + item: { + id: 201, + item_name: "yogurt", + quantity: 1, + bought: false, + }, + }), + }); + }); + + await page.route("**/households/1/stores/10/list", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: currentItem ? [currentItem] : [], + }), + }); + }); + + return { + setClassificationRequestMode(mode: "success" | "error") { + classificationRequestMode = mode; + }, + }; +} + +async function openEditModal(itemRow: ReturnType, page: import("@playwright/test").Page) { + await itemRow.dispatchEvent("mousedown"); + await page.waitForTimeout(650); + await itemRow.dispatchEvent("mouseup"); + await expect(page.locator(".edit-modal-content")).toBeVisible(); +} + +test("add-details modal validates with toasts and persists classification details", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + await setupGroceryListRoutes(page); + + let dialogSeen = false; + page.on("dialog", async (dialog) => { + dialogSeen = true; + await dialog.dismiss(); + }); + + await page.goto("/"); + + await page.getByPlaceholder("Enter item name").fill("yogurt"); + await page.getByRole("button", { name: "Create + Add" }).click(); + + const addDetailsModal = page.locator(".add-item-details-modal"); + await expect(addDetailsModal).toBeVisible(); + + await addDetailsModal.locator(".add-item-details-select").nth(0).selectOption("dairy"); + await addDetailsModal.getByRole("button", { name: "Add Item" }).click(); + + await expect(page.locator(".action-toast.action-toast-error")).toContainText("Select an item group"); + expect(dialogSeen).toBe(false); + + await addDetailsModal.locator(".add-item-details-select").nth(1).selectOption("Milk"); + await addDetailsModal.locator(".add-item-details-select").nth(2).selectOption("Dairy & Refrigerated"); + await addDetailsModal.getByRole("button", { name: "Add Item" }).click(); + + const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" }); + await expect(yogurtRow).toBeVisible(); + await expect( + page.locator(".action-toast.action-toast-success").filter({ hasText: "Added item" }) + ).toContainText("Added item"); + + await openEditModal(yogurtRow, page); + + const editModal = page.locator(".edit-modal-content"); + await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("dairy"); + await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Milk"); + await expect(editModal.locator(".edit-modal-select").nth(2)).toHaveValue("Dairy & Refrigerated"); +}); + +test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + const routes = await setupGroceryListRoutes(page); + + await page.goto("/"); + + await page.getByPlaceholder("Enter item name").fill("yogurt"); + await page.getByRole("button", { name: "Create + Add" }).click(); + await page.locator(".add-item-details-modal").getByRole("button", { name: "Skip All" }).click(); + + const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" }); + await expect(yogurtRow).toBeVisible(); + + await openEditModal(yogurtRow, page); + + let editModal = page.locator(".edit-modal-content"); + await editModal.locator(".edit-modal-select").nth(0).selectOption(""); + await editModal.locator(".edit-modal-select").nth(1).selectOption("Checkout Area"); + await editModal.getByRole("button", { name: "Save Changes" }).click(); + + await expect( + page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated item" }) + ).toContainText("Updated item"); + await expect(editModal).toBeHidden(); + + await openEditModal(yogurtRow, page); + editModal = page.locator(".edit-modal-content"); + await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue(""); + await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Checkout Area"); + + routes.setClassificationRequestMode("error"); + await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery"); + await editModal.getByRole("button", { name: "Save Changes" }).click(); + + await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone"); +}); diff --git a/frontend/tests/grocery-list-assignment.spec.ts b/frontend/tests/grocery-list-assignment.spec.ts new file mode 100644 index 0000000..263021f --- /dev/null +++ b/frontend/tests/grocery-list-assignment.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from "@playwright/test"; +import { + mockConfig, + mockHouseholdAndStoreShell, + seedAuthStorage, +} from "./helpers/e2e"; + +test("assigned items render selected users and keep the picker menu outside the modal", async ({ page }) => { + await seedAuthStorage(page, { username: "assignment-user" }); + await mockConfig(page); + + const members = [ + { id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" }, + { id: 2, username: "casey", name: "Casey Client", display_name: "Casey Client", role: "member" }, + { id: 3, username: "jordan", name: "Jordan Client", display_name: "Jordan Client", role: "member" }, + { id: 4, username: "alex", name: "Alex Member", display_name: "Alex Member", role: "member" }, + { id: 5, username: "morgan", name: "Morgan Member", display_name: "Morgan Member", role: "member" }, + { id: 6, username: "sam", name: "Sam Member", display_name: "Sam Member", role: "member" }, + { id: 7, username: "jamie", name: "Jamie Member", display_name: "Jamie Member", role: "member" }, + { id: 8, username: "pat", name: "Pat Member", display_name: "Pat Member", role: "member" }, + { id: 9, username: "drew", name: "Drew Member", display_name: "Drew Member", role: "member" }, + { id: 10, username: "kai", name: "Kai Member", display_name: "Kai Member", role: "member" }, + { id: 11, username: "blair", name: "Blair Member", display_name: "Blair Member", role: "member" }, + { id: 12, username: "quinn", name: "Quinn Member", display_name: "Quinn Member", role: "member" }, + { id: 13, username: "rowan", name: "Rowan Member", display_name: "Rowan Member", role: "member" }, + { id: 14, username: "sage", name: "Sage Member", display_name: "Sage Member", role: "member" }, + { id: 15, username: "taylor", name: "Taylor Member", display_name: "Taylor Member", role: "member" }, + { id: 16, username: "river", name: "River Member", display_name: "River Member", role: "member" }, + ]; + + let listItems: Array<{ + id: number; + item_id: number; + item_name: string; + quantity: number; + bought: boolean; + item_image: string | null; + image_mime_type: string | null; + added_by_users: string[]; + last_added_on: string; + item_type: string | null; + item_group: string | null; + zone: string | null; + }> = []; + let addCallCount = 0; + + await mockHouseholdAndStoreShell(page, { + household: { name: "Assignment House" }, + }); + + await page.route("**/households/1/members", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(members), + }); + }); + + await page.route("**/households/1/stores/10/list/recent", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/stores/10/list/suggestions**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/stores/10/list/item**", async (route) => { + const url = new URL(route.request().url()); + const itemName = (url.searchParams.get("item_name") || "").toLowerCase(); + const item = listItems.find((candidate) => candidate.item_name === itemName); + + await route.fulfill({ + status: item ? 200 : 404, + contentType: "application/json", + body: JSON.stringify(item || { message: "Item not found" }), + }); + }); + + await page.route("**/households/1/stores/10/list/add", async (route) => { + addCallCount += 1; + + if (addCallCount === 1) { + listItems = [ + { + id: 201, + item_id: 501, + item_name: "bananas", + quantity: 1, + bought: false, + item_image: null, + image_mime_type: null, + added_by_users: ["Casey Client"], + last_added_on: "2026-03-28T12:00:00.000Z", + item_type: null, + item_group: null, + zone: null, + }, + ]; + } else { + listItems = [ + { + id: 201, + item_id: 501, + item_name: "bananas", + quantity: 2, + bought: false, + item_image: null, + image_mime_type: null, + added_by_users: ["Casey Client", "Jordan Client"], + last_added_on: "2026-03-28T12:05:00.000Z", + item_type: null, + item_group: null, + zone: null, + }, + ]; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: addCallCount === 1 ? "Item added" : "Item updated", + item: { + id: 201, + item_name: "bananas", + quantity: addCallCount === 1 ? 1 : 2, + bought: false, + }, + }), + }); + }); + + await page.route("**/households/1/stores/10/list", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: listItems }), + }); + }); + + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Grocery List" })).toBeVisible(); + await page.getByPlaceholder("Enter item name").fill("bananas"); + await page.getByRole("button", { name: "Others" }).click(); + + const assignModal = page.locator(".assign-item-for-modal"); + await expect(assignModal).toBeVisible(); + + await assignModal.locator(".assign-item-for-dropdown-trigger").click(); + + const portalMenu = page.locator("body > .assign-item-for-dropdown-menu"); + await expect(portalMenu).toBeVisible(); + await expect(page.locator(".assign-item-for-modal .assign-item-for-dropdown-menu")).toHaveCount(0); + + const dropdownMetrics = await portalMenu.evaluate((element) => { + const menu = element as HTMLDivElement; + return { + position: window.getComputedStyle(menu).position, + scrollable: menu.scrollHeight > menu.clientHeight, + }; + }); + + expect(dropdownMetrics.position).toBe("fixed"); + expect(dropdownMetrics.scrollable).toBe(true); + + await portalMenu.getByRole("option", { name: "Casey Client" }).click(); + await expect(portalMenu).toHaveCount(0); + await assignModal.getByRole("button", { name: "Confirm" }).click(); + await expect(assignModal).toHaveCount(0); + + await expect(page.getByText("Adding for: Casey Client")).toBeVisible(); + await page.getByRole("button", { name: "Create + Add" }).click(); + await page.getByRole("button", { name: "Skip All" }).click(); + + const bananasRow = page.locator(".glist-li").filter({ hasText: "bananas" }); + await expect(bananasRow).toContainText("Casey Client"); + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item"); + + await page.getByPlaceholder("Enter item name").fill("bananas"); + await page.getByRole("button", { name: "Others" }).click(); + await assignModal.locator(".assign-item-for-dropdown-trigger").click(); + await portalMenu.getByRole("option", { name: "Jordan Client" }).click(); + await expect(portalMenu).toHaveCount(0); + await assignModal.getByRole("button", { name: "Confirm" }).click(); + await expect(assignModal).toHaveCount(0); + + await expect(page.getByText("Adding for: Jordan Client")).toBeVisible(); + await page.getByRole("button", { name: "Create + Add" }).click(); + await page.getByRole("button", { name: "Update Quantity" }).click(); + + await expect(bananasRow).toContainText("Casey Client"); + await expect(bananasRow).toContainText("Jordan Client"); + await expect( + page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated item quantity" }) + ).toContainText("Updated item quantity"); +}); diff --git a/frontend/tests/helpers/e2e.ts b/frontend/tests/helpers/e2e.ts new file mode 100644 index 0000000..1f4cbc2 --- /dev/null +++ b/frontend/tests/helpers/e2e.ts @@ -0,0 +1,120 @@ +import { expect, type Page } from "@playwright/test"; + +type AuthSeed = { + token?: string; + userId?: string; + role?: string; + username?: string; +}; + +type HouseholdSeed = { + id?: number; + name?: string; + role?: string; + invite_code?: string; +}; + +type StoreSeed = { + id?: number; + name?: string; + location?: string; + is_default?: boolean; +}; + +const defaultConfig = { + maxFileSizeMB: 20, + maxImageDimension: 800, + imageQuality: 85, +}; + +export function seedAuthStorage(page: Page, seed: AuthSeed = {}) { + return page.addInitScript((authSeed) => { + localStorage.setItem("token", authSeed.token || "test-token"); + localStorage.setItem("userId", authSeed.userId || "1"); + localStorage.setItem("role", authSeed.role || "admin"); + localStorage.setItem("username", authSeed.username || "test-user"); + }, seed); +} + +export async function mockConfig(page: Page, overrides = {}) { + await page.route("**/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ...defaultConfig, ...overrides }), + }); + }); +} + +export async function mockHouseholdAndStoreShell( + page: Page, + options: { household?: HouseholdSeed; stores?: StoreSeed[] } = {} +) { + const household = { + id: 1, + name: "Test Household", + role: "admin", + invite_code: "ABCD1234", + ...options.household, + }; + const stores = options.stores || [ + { id: 10, name: "Costco", location: "Warehouse", is_default: true }, + ]; + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([household]), + }); + }); + + await page.route(`**/stores/household/${household.id}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(stores), + }); + }); +} + +export async function confirmSlide(page: Page) { + const confirmModal = page.locator(".confirm-slide-modal"); + await expect(confirmModal).toBeVisible(); + + const slider = confirmModal.locator(".confirm-slide-handle"); + const track = confirmModal.locator(".confirm-slide-track"); + const sliderBox = await slider.boundingBox(); + const trackBox = await track.boundingBox(); + + if (!sliderBox || !trackBox) { + throw new Error("Confirm slide control was not measurable"); + } + + await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2); + await page.mouse.down(); + await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { + steps: 8, + }); + await page.mouse.up(); +} + +export function collectFailedApiRequests(page: Page) { + const failedRequests: string[] = []; + + page.on("requestfailed", (request) => { + const url = request.url(); + if (!url.startsWith("http://localhost:5000")) { + return; + } + + const failure = request.failure()?.errorText || "unknown failure"; + failedRequests.push(`${request.method()} ${url} :: ${failure}`); + }); + + return failedRequests; +} + +export function expectNoFailedApiRequests(failedRequests: string[]) { + expect(failedRequests).toEqual([]); +} diff --git a/frontend/tests/household-onboarding.spec.ts b/frontend/tests/household-onboarding.spec.ts new file mode 100644 index 0000000..0f644ee --- /dev/null +++ b/frontend/tests/household-onboarding.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import { mockConfig, seedAuthStorage } from "./helpers/e2e"; + +test("new users with no households see create and join actions instead of a loading dead-end", async ({ page }) => { + await seedAuthStorage(page, { username: "new-user" }); + await mockConfig(page); + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Create or Join Household" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "No household yet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Create Household", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Join Household", exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Join Household", exact: true }).click(); + await expect(page.getByLabel("Invite Link")).toBeVisible(); + await page.getByRole("button", { name: "Close household dialog" }).click(); + + await page.getByRole("button", { name: "Create Household", exact: true }).click(); + await expect(page.getByLabel("Household Name")).toBeVisible(); + await page.getByRole("button", { name: "Close household dialog" }).click(); + + await page.goto("/manage"); + + await expect(page.getByRole("heading", { name: "Manage" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "No household yet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Create Household", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Join Household", exact: true })).toBeVisible(); +}); diff --git a/frontend/tests/household-selection-persistence.spec.ts b/frontend/tests/household-selection-persistence.spec.ts new file mode 100644 index 0000000..04de148 --- /dev/null +++ b/frontend/tests/household-selection-persistence.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; + +function seedAuthStorage(page: import("@playwright/test").Page) { + return page.addInitScript(() => { + localStorage.setItem("token", "test-token"); + localStorage.setItem("userId", "1"); + localStorage.setItem("role", "admin"); + localStorage.setItem("username", "persistent-user"); + }); +} + +async function mockConfig(page: import("@playwright/test").Page) { + await page.route("**/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + maxFileSizeMB: 20, + maxImageDimension: 800, + imageQuality: 85, + }), + }); + }); +} + +test("selected household stays active after refreshing on settings and home pages", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + + const households = [ + { id: 1, name: "Alpha Home", role: "owner" }, + { id: 2, name: "Bravo Home", role: "admin" }, + ]; + + const storesByHousehold = { + 1: [{ id: 101, name: "Costco", is_default: true }], + 2: [{ id: 201, name: "Trader Joe's", is_default: true }], + }; + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(households), + }); + }); + + await page.route("**/stores/household/*", async (route) => { + const householdId = Number(route.request().url().split("/").pop()); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + storesByHousehold[householdId as keyof typeof storesByHousehold] ?? [] + ), + }); + }); + + await page.route("**/households/*/stores/*/list/recent", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/*/stores/*/list", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [] }), + }); + }); + + await page.route("**/households/*/members", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: 1, username: "persistent-user", role: "owner" }]), + }); + }); + + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible(); + + await page.getByRole("button", { name: "Alpha Home" }).click(); + await page.getByRole("button", { name: "Bravo Home" }).click(); + + await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2"); + + await page.goto("/settings"); + await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible(); + + await page.reload(); + await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2"); + + await page.goto("/"); + await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Trader Joe's" })).toBeVisible(); +}); diff --git a/frontend/tests/invite-link-management.spec.ts b/frontend/tests/invite-link-management.spec.ts new file mode 100644 index 0000000..70f0678 --- /dev/null +++ b/frontend/tests/invite-link-management.spec.ts @@ -0,0 +1,293 @@ +import { expect, test } from "@playwright/test"; +import { + collectFailedApiRequests, + confirmSlide, + expectNoFailedApiRequests, + mockConfig, + seedAuthStorage, +} from "./helpers/e2e"; + +test("join household modal accepts invite links but rejects legacy invite codes", async ({ page }) => { + await seedAuthStorage(page, { username: "manager-user" }); + await mockConfig(page); + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/api/invite-links/approvaltoken", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + link: { + token: "approvaltoken", + status: "ACTIVE", + viewerStatus: null, + active_policy: "APPROVAL_REQUIRED", + group_name: "Approval Home", + }, + }), + }); + }); + + await page.goto("/manage"); + await page.getByRole("button", { name: "Join Household", exact: true }).click(); + await page.getByLabel("Invite Link").fill("HABC123"); + await page.getByRole("button", { name: "Open Invite" }).click(); + + await expect(page.locator(".create-join-modal .error-message")).toHaveText( + "Use a household invite link like /invite/abcd1234." + ); + + await page.getByLabel("Invite Link").fill("/invite/approvaltoken"); + await page.getByRole("button", { name: "Open Invite" }).click(); + + await expect(page).toHaveURL(/\/invite\/approvaltoken$/); + await expect(page.getByRole("heading", { name: "Join Approval Home" })).toBeVisible(); +}); + +test("household management shows pending invite approvals and can approve them", async ({ page }) => { + await seedAuthStorage(page, { username: "manager-user" }); + await mockConfig(page); + + let members = [ + { id: 1, username: "manager-user", role: "owner" }, + ]; + let pendingRequests = [ + { + id: 41, + user_id: 7, + username: "pending-pal", + name: "Pending Pal", + display_name: "", + created_at: "2026-03-31T12:00:00.000Z", + status: "PENDING", + }, + ]; + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }]), + }); + }); + + await page.route("**/households/1/members", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(members), + }); + }); + + await page.route("**/api/groups/join-policy", async (route) => { + const request = route.request(); + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }); + }); + + await page.route("**/api/groups/invites", async (route) => { + const request = route.request(); + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + links: [ + { + id: 9, + token: "invite-token-1234", + policy: "APPROVAL_REQUIRED", + single_use: false, + expires_at: "2030-01-01T00:00:00.000Z", + used_at: null, + revoked_at: null, + }, + ], + }), + }); + return; + } + + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ link: { id: 10, token: "new-token" } }), + }); + }); + + await page.route("**/api/groups/join-requests", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ requests: pendingRequests }), + }); + }); + + await page.route("**/api/groups/join-requests/decision", async (route) => { + const body = route.request().postDataJSON() as { requestId?: number; decision?: string }; + if (body.requestId === 41 && body.decision === "APPROVE") { + pendingRequests = []; + members = [ + ...members, + { id: 7, username: "pending-pal", role: "member" }, + ]; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + request: { + id: 41, + user_id: 7, + status: body.decision === "APPROVE" ? "APPROVED" : "DENIED", + }, + }), + }); + }); + + await page.goto("/manage?tab=household"); + + await expect(page.getByRole("heading", { name: "Invite Links" })).toBeVisible(); + await expect(page.getByText("Pending Pal")).toBeVisible(); + await expect(page.getByText("Invite Code")).toHaveCount(0); + + await page.getByRole("button", { name: "Approve" }).click(); + + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Approved join request"); + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal"); + await expect(page.getByText("No pending join requests right now.")).toBeVisible(); + await expect(page.getByText("Members (2)")).toBeVisible(); +}); + +test("household owner can transfer ownership from household settings", async ({ page }) => { + const failedApiRequests = collectFailedApiRequests(page); + await seedAuthStorage(page, { role: "owner", username: "manager-user" }); + await mockConfig(page); + + let households = [{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }]; + let members = [ + { id: 1, username: "manager-user", role: "owner" }, + { id: 2, username: "nico-admin", role: "admin" }, + ]; + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(households), + }); + }); + + await page.route("**/households/1/members", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(members), + }); + }); + + await page.route("**/households/1/members/*/role", async (route) => { + const request = route.request(); + if (request.method() !== "PATCH") { + await route.fulfill({ status: 405 }); + return; + } + + const body = request.postDataJSON() as { role?: string }; + if (body.role === "owner") { + households = [{ id: 1, name: "Approval Home", role: "admin", invite_code: "ABCD1234" }]; + members = [ + { id: 1, username: "manager-user", role: "admin" }, + { id: 2, username: "nico-admin", role: "owner" }, + ]; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: "Household ownership transferred successfully", + member: { user_id: 2, role: body.role || "member" }, + }), + }); + }); + + await page.route("**/api/groups/join-policy", async (route) => { + const request = route.request(); + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }); + }); + + await page.route("**/api/groups/invites", async (route) => { + const request = route.request(); + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ links: [] }), + }); + return; + } + + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ link: { id: 10, token: "new-token" } }), + }); + }); + + await page.route("**/api/groups/join-requests", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ requests: [] }), + }); + }); + + await page.goto("/manage?tab=household"); + + await expect(page.getByRole("button", { name: "Make Owner" })).toBeVisible(); + + await page.getByRole("button", { name: "Make Owner" }).click(); + await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible(); + await confirmSlide(page); + + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Transferred household ownership"); + await expect(page.locator(".action-toast.action-toast-success")).toContainText("nico-admin"); + await expect(page.getByText("👑 Owner")).toContainText("Owner"); + await expect(page.getByText("🛠️ Admin")).toContainText("Admin"); + await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0); + expectNoFailedApiRequests(failedApiRequests); +}); diff --git a/frontend/tests/toast-notifications.spec.ts b/frontend/tests/toast-notifications.spec.ts new file mode 100644 index 0000000..ba89553 --- /dev/null +++ b/frontend/tests/toast-notifications.spec.ts @@ -0,0 +1,272 @@ +import { expect, test } from "@playwright/test"; + +function seedAuthStorage(page: import("@playwright/test").Page) { + return page.addInitScript(() => { + localStorage.setItem("token", "test-token"); + localStorage.setItem("userId", "1"); + localStorage.setItem("role", "admin"); + localStorage.setItem("username", "toast-user"); + }); +} + +async function mockConfig(page: import("@playwright/test").Page) { + await page.route("**/config", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + maxFileSizeMB: 20, + maxImageDimension: 800, + imageQuality: 85, + }), + }); + }); +} + +test("login failure shows inline error and error toast", async ({ page }) => { + await mockConfig(page); + await page.route("**/auth/login", async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ message: "Invalid credentials" }), + }); + }); + + await page.goto("/login"); + await page.getByPlaceholder("Username").fill("bad-user"); + await page.getByPlaceholder("Password").fill("bad-password"); + await page.getByRole("button", { name: "Login" }).click(); + + await expect(page.getByText("Invalid credentials", { exact: true })).toBeVisible(); + await expect(page.locator(".action-toast.action-toast-error")).toContainText("Login failed"); + await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid credentials"); +}); + +test("manage stores add success shows success toast", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + + let linkedStoreIds = [10]; + const allStores = [ + { id: 10, name: "Costco North", location: "North", is_default: true }, + { id: 11, name: "Costco South", location: "South", is_default: false }, + ]; + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: 1, name: "Toast Home", role: "admin", invite_code: "ABCD1234" }]), + }); + }); + + await page.route("**/stores/household/1", async (route) => { + const request = route.request(); + if (request.method() === "GET") { + const payload = linkedStoreIds.map((id, index) => { + const store = allStores.find((candidate) => candidate.id === id); + return { + ...store, + is_default: index === 0, + }; + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(payload), + }); + return; + } + + if (request.method() === "POST") { + const body = request.postDataJSON() as { storeId?: number }; + if (body.storeId && !linkedStoreIds.includes(body.storeId)) { + linkedStoreIds = [...linkedStoreIds, body.storeId]; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }); + return; + } + + await route.fallback(); + }); + + await page.route("**/stores", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(allStores), + }); + }); + + await page.goto("/manage?tab=stores"); + await page.getByRole("button", { name: "+ Add Store" }).click(); + await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click(); + + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store"); + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South"); +}); + +test("manage stores add failure shows normalized error toast", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + + const allStores = [ + { id: 10, name: "Costco North", location: "North", is_default: true }, + { id: 11, name: "Costco South", location: "South", is_default: false }, + ]; + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: 1, name: "Toast Home", role: "admin", invite_code: "ABCD1234" }]), + }); + }); + + await page.route("**/stores/household/1", async (route) => { + const request = route.request(); + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]), + }); + return; + } + + if (request.method() === "POST") { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ + error: { message: "Store already linked to household" }, + }), + }); + return; + } + + await route.fallback(); + }); + + await page.route("**/stores", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(allStores), + }); + }); + + await page.goto("/manage?tab=stores"); + await page.getByRole("button", { name: "+ Add Store" }).click(); + await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click(); + + await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed"); + await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household"); +}); + +test("invite accept JOINED shows success toast", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + + await page.route("**/api/invite-links/toast-token", async (route) => { + const request = route.request(); + + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + link: { + token: "toast-token", + status: "ACTIVE", + viewerStatus: null, + active_policy: "AUTO_ACCEPT", + group_name: "Toast Group", + }, + }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + result: { + status: "JOINED", + group: { name: "Toast Group" }, + }, + }), + }); + }); + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: 1, name: "Toast Home", role: "member", invite_code: "ABCD1234" }]), + }); + }); + + await page.goto("/invite/toast-token"); + await page.getByRole("button", { name: "Join Group" }).click(); + + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Joined group"); + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Toast Group"); +}); + +test("invite accept PENDING shows info toast", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(page); + + await page.route("**/api/invite-links/pending-token", async (route) => { + const request = route.request(); + + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + link: { + token: "pending-token", + status: "ACTIVE", + viewerStatus: null, + active_policy: "APPROVAL_REQUIRED", + group_name: "Pending Group", + }, + }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + result: { + status: "PENDING", + group: { name: "Pending Group" }, + }, + }), + }); + }); + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ id: 1, name: "Toast Home", role: "member", invite_code: "ABCD1234" }]), + }); + }); + + await page.goto("/invite/pending-token"); + await page.getByRole("button", { name: "Join Group" }).click(); + + await expect(page.locator(".action-toast.action-toast-info")).toContainText("Join request sent"); + await expect(page.locator(".action-toast.action-toast-info")).toContainText("Pending Group"); +}); diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..6e8368d --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + testEnvironment: "node", + roots: ["/backend/tests"], + clearMocks: true, +}; diff --git a/package-lock.json b/package-lock.json index 5845a6d..d10a30f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,10 @@ { - "name": "Costco-Grocery-List", + "name": "grocery-app", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "Costco-Grocery-List", + "name": "grocery-app", "devDependencies": { "cross-env": "^10.1.0", "jest": "^30.2.0", @@ -1544,9 +1544,9 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "dependencies": { "balanced-match": "^1.0.0" @@ -3130,9 +3130,9 @@ } }, "node_modules/jest-util/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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "engines": { "node": ">=12" @@ -3421,12 +3421,12 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "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.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3671,9 +3671,9 @@ "dev": true }, "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, "engines": { "node": ">=8.6" @@ -3746,9 +3746,9 @@ ] }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "dependencies": { "side-channel": "^1.1.0" @@ -4191,9 +4191,9 @@ } }, "node_modules/test-exclude/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.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", @@ -4222,9 +4222,9 @@ } }, "node_modules/test-exclude/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, "dependencies": { "brace-expansion": "^1.1.7" diff --git a/package.json b/package.json index 96a5802..44595c3 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,29 @@ { + "name": "grocery-app", + "private": true, + "scripts": { + "dev:backend": "npm --prefix backend run dev", + "dev:frontend": "npm --prefix frontend run dev", + "build": "npm run build:backend && npm run build:frontend", + "build:backend": "npm --prefix backend run build", + "build:frontend": "npm --prefix frontend run build", + "lint": "npm --prefix frontend run lint", + "typecheck": "npm --prefix frontend run typecheck", + "audit": "npm run audit:root && npm run audit:backend && npm run audit:frontend", + "audit:root": "npm audit", + "audit:backend": "npm --prefix backend audit", + "audit:frontend": "npm --prefix frontend audit", + "db:migrate": "node scripts/db-migrate.js", + "db:migrate:status": "node scripts/db-migrate-status.js", + "db:migrate:verify": "node scripts/db-migrate-verify.js", + "db:migrate:new": "node scripts/db-migrate-new.js", + "db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write", + "db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale", + "test": "jest --runInBand", + "test:e2e": "npm --prefix frontend run test:e2e --", + "test:e2e:headed": "npm --prefix frontend run test:e2e:headed --", + "test:e2e:ui": "npm --prefix frontend run test:e2e:ui --" + }, "devDependencies": { "cross-env": "^10.1.0", "jest": "^30.2.0", diff --git a/packages/db/migrations/20260328_010000_add_household_store_available_items.sql b/packages/db/migrations/20260328_010000_add_household_store_available_items.sql new file mode 100644 index 0000000..be70b0a --- /dev/null +++ b/packages/db/migrations/20260328_010000_add_household_store_available_items.sql @@ -0,0 +1,24 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS household_store_available_items ( + id SERIAL PRIMARY KEY, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + custom_image BYTEA, + custom_image_mime_type VARCHAR(50), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id, item_id) +); + +CREATE INDEX IF NOT EXISTS idx_available_items_household_store + ON household_store_available_items(household_id, store_id); + +CREATE INDEX IF NOT EXISTS idx_available_items_item + ON household_store_available_items(item_id); + +COMMENT ON TABLE household_store_available_items IS 'Curated household-store item catalogs'; +COMMENT ON COLUMN household_store_available_items.custom_image IS 'Optional store-specific image override'; + +COMMIT; diff --git a/packages/db/migrations/20260329_010000_add_household_store_items.sql b/packages/db/migrations/20260329_010000_add_household_store_items.sql new file mode 100644 index 0000000..0ebb7f7 --- /dev/null +++ b/packages/db/migrations/20260329_010000_add_household_store_items.sql @@ -0,0 +1,199 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS household_store_items ( + id SERIAL PRIMARY KEY, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + normalized_name VARCHAR(255) NOT NULL, + custom_image BYTEA, + custom_image_mime_type VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(household_id, store_id, normalized_name) +); + +CREATE INDEX IF NOT EXISTS idx_household_store_items_household_store + ON household_store_items(household_id, store_id); + +CREATE INDEX IF NOT EXISTS idx_household_store_items_lookup + ON household_store_items(household_id, store_id, normalized_name); + +COMMENT ON TABLE household_store_items IS 'Household + store owned item records used for list suggestions and management'; +COMMENT ON COLUMN household_store_items.normalized_name IS 'Lowercased trimmed item name used for exact household/store matching'; + +ALTER TABLE household_lists + ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER; + +ALTER TABLE household_item_classifications + ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER; + +ALTER TABLE household_list_history + ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER; + +INSERT INTO household_store_items ( + household_id, + store_id, + name, + normalized_name, + created_at, + updated_at +) +SELECT DISTINCT + hl.household_id, + hl.store_id, + LOWER(TRIM(i.name)) AS name, + LOWER(TRIM(i.name)) AS normalized_name, + COALESCE(MIN(hl.modified_on) OVER (PARTITION BY hl.household_id, hl.store_id, LOWER(TRIM(i.name))), NOW()) AS created_at, + COALESCE(MAX(hl.modified_on) OVER (PARTITION BY hl.household_id, hl.store_id, LOWER(TRIM(i.name))), NOW()) AS updated_at +FROM household_lists hl +JOIN items i ON i.id = hl.item_id +ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING; + +DO $$ +BEGIN + IF to_regclass('public.household_store_available_items') IS NOT NULL THEN + INSERT INTO household_store_items ( + household_id, + store_id, + name, + normalized_name, + custom_image, + custom_image_mime_type, + created_at, + updated_at + ) + SELECT + hsai.household_id, + hsai.store_id, + LOWER(TRIM(i.name)) AS name, + LOWER(TRIM(i.name)) AS normalized_name, + hsai.custom_image, + hsai.custom_image_mime_type, + COALESCE(hsai.created_at, NOW()), + COALESCE(hsai.updated_at, NOW()) + FROM household_store_available_items hsai + JOIN items i ON i.id = hsai.item_id + ON CONFLICT (household_id, store_id, normalized_name) DO UPDATE + SET + custom_image = COALESCE(household_store_items.custom_image, EXCLUDED.custom_image), + custom_image_mime_type = COALESCE( + household_store_items.custom_image_mime_type, + EXCLUDED.custom_image_mime_type + ), + updated_at = GREATEST(household_store_items.updated_at, EXCLUDED.updated_at); + END IF; +END $$; + +UPDATE household_lists hl +SET household_store_item_id = hsi.id +FROM items i, + household_store_items hsi +WHERE hl.item_id = i.id + AND hsi.household_id = hl.household_id + AND hsi.store_id = hl.store_id + AND hsi.normalized_name = LOWER(TRIM(i.name)) + AND hl.household_store_item_id IS NULL; + +UPDATE household_item_classifications hic +SET household_store_item_id = hsi.id +FROM items i, + household_store_items hsi +WHERE hic.item_id = i.id + AND hsi.household_id = hic.household_id + AND hsi.store_id = hic.store_id + AND hsi.normalized_name = LOWER(TRIM(i.name)) + AND hic.household_store_item_id IS NULL; + +DELETE FROM household_list_history hlh +WHERE NOT EXISTS ( + SELECT 1 + FROM household_lists hl + WHERE hl.id = hlh.household_list_id +); + +UPDATE household_list_history hlh +SET household_store_item_id = hl.household_store_item_id +FROM household_lists hl +WHERE hlh.household_list_id = hl.id + AND hlh.household_store_item_id IS NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM household_lists + WHERE household_store_item_id IS NULL + ) THEN + RAISE EXCEPTION 'Failed to backfill household_lists.household_store_item_id'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM household_item_classifications + WHERE household_store_item_id IS NULL + ) THEN + RAISE EXCEPTION 'Failed to backfill household_item_classifications.household_store_item_id'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM household_list_history + WHERE household_store_item_id IS NULL + ) THEN + RAISE EXCEPTION 'Failed to backfill household_list_history.household_store_item_id'; + END IF; +END $$; + +ALTER TABLE household_lists + ALTER COLUMN household_store_item_id SET NOT NULL; + +ALTER TABLE household_item_classifications + ALTER COLUMN household_store_item_id SET NOT NULL; + +ALTER TABLE household_list_history + ALTER COLUMN household_store_item_id SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'household_lists_household_store_item_id_fkey' + ) THEN + ALTER TABLE household_lists + ADD CONSTRAINT household_lists_household_store_item_id_fkey + FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'household_item_classifications_household_store_item_id_fkey' + ) THEN + ALTER TABLE household_item_classifications + ADD CONSTRAINT household_item_classifications_household_store_item_id_fkey + FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'household_list_history_household_store_item_id_fkey' + ) THEN + ALTER TABLE household_list_history + ADD CONSTRAINT household_list_history_household_store_item_id_fkey + FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_household_lists_household_store_item + ON household_lists(household_id, store_id, household_store_item_id); + +CREATE INDEX IF NOT EXISTS idx_household_item_classifications_household_store_item + ON household_item_classifications(household_id, store_id, household_store_item_id); + +CREATE INDEX IF NOT EXISTS idx_household_list_history_household_store_item + ON household_list_history(household_store_item_id); + +COMMIT; diff --git a/packages/db/migrations/20260329_020000_fix_household_item_classification_upsert.sql b/packages/db/migrations/20260329_020000_fix_household_item_classification_upsert.sql new file mode 100644 index 0000000..29e0133 --- /dev/null +++ b/packages/db/migrations/20260329_020000_fix_household_item_classification_upsert.sql @@ -0,0 +1,23 @@ +BEGIN; + +WITH ranked_classifications AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY household_id, store_id, household_store_item_id + ORDER BY updated_at DESC NULLS LAST, id DESC + ) AS row_rank + FROM household_item_classifications + WHERE household_store_item_id IS NOT NULL +) +DELETE FROM household_item_classifications hic +USING ranked_classifications ranked +WHERE hic.id = ranked.id + AND ranked.row_rank > 1; + +DROP INDEX IF EXISTS idx_household_item_classifications_household_store_item; + +CREATE UNIQUE INDEX idx_household_item_classifications_household_store_item + ON household_item_classifications(household_id, store_id, household_store_item_id); + +COMMIT; diff --git a/packages/db/migrations/README.md b/packages/db/migrations/README.md new file mode 100644 index 0000000..9e590be --- /dev/null +++ b/packages/db/migrations/README.md @@ -0,0 +1,15 @@ +# Migration Directory + +This directory is the canonical location for SQL migrations. + +- Use `npm run db:migrate` to apply pending migrations. +- Use `npm run db:migrate:status` to view applied/pending migrations. +- Use `npm run db:migrate:verify` to fail when pending migrations exist. +- Use `npm run db:migrate:new -- ` to create a new migration file. + +Do not place new canonical migrations under `backend/migrations`. + +## Stale baseline +- `stale-files.json` lists canonical SQL files intentionally treated as stale. +- Files listed there are skipped by migration commands by default. +- Add only genuinely new migration files when schema changes are required. diff --git a/packages/db/migrations/add_display_name_column.sql b/packages/db/migrations/add_display_name_column.sql new file mode 100644 index 0000000..37e559e --- /dev/null +++ b/packages/db/migrations/add_display_name_column.sql @@ -0,0 +1,10 @@ +-- Add display_name column to users table +-- This allows users to have a friendly name separate from their username + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS display_name VARCHAR(100); + +-- Set display_name to name for existing users (as default) +UPDATE users +SET display_name = name +WHERE display_name IS NULL; diff --git a/packages/db/migrations/add_image_columns.sql b/packages/db/migrations/add_image_columns.sql new file mode 100644 index 0000000..9777037 --- /dev/null +++ b/packages/db/migrations/add_image_columns.sql @@ -0,0 +1,8 @@ +-- Add image columns to grocery_list table +ALTER TABLE grocery_list +ADD COLUMN IF NOT EXISTS item_image BYTEA, +ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50); + +-- Index to speed up queries that filter by rows with images. +CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image +ON grocery_list ((item_image IS NOT NULL)); diff --git a/packages/db/migrations/add_modified_on_column.sql b/packages/db/migrations/add_modified_on_column.sql new file mode 100644 index 0000000..2034edc --- /dev/null +++ b/packages/db/migrations/add_modified_on_column.sql @@ -0,0 +1,8 @@ +-- Add modified_on column to grocery_list table +ALTER TABLE grocery_list +ADD COLUMN modified_on TIMESTAMP DEFAULT NOW(); + +-- Set modified_on to NOW() for existing records +UPDATE grocery_list +SET modified_on = NOW() +WHERE modified_on IS NULL; diff --git a/packages/db/migrations/add_notes_column.sql b/packages/db/migrations/add_notes_column.sql new file mode 100644 index 0000000..977dd22 --- /dev/null +++ b/packages/db/migrations/add_notes_column.sql @@ -0,0 +1,7 @@ +-- Add notes column to household_lists table +-- This allows users to add custom notes/descriptions to list items + +ALTER TABLE household_lists +ADD COLUMN IF NOT EXISTS notes TEXT; + +COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item'; diff --git a/packages/db/migrations/create_item_classification_table.sql b/packages/db/migrations/create_item_classification_table.sql new file mode 100644 index 0000000..effc63a --- /dev/null +++ b/packages/db/migrations/create_item_classification_table.sql @@ -0,0 +1,29 @@ +-- Migration: Create item_classification table +-- This table stores classification data for items in the grocery_list table +-- Each row in grocery_list can have ONE corresponding classification row + +CREATE TABLE IF NOT EXISTS item_classification ( + id INTEGER PRIMARY KEY REFERENCES grocery_list(id) ON DELETE CASCADE, + item_type VARCHAR(50) NOT NULL, + item_group VARCHAR(100) NOT NULL, + zone VARCHAR(100), + confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1), + source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Index for faster lookups by type +CREATE INDEX IF NOT EXISTS idx_item_classification_type ON item_classification(item_type); + +-- Index for zone-based queries +CREATE INDEX IF NOT EXISTS idx_item_classification_zone ON item_classification(zone); + +-- Comments +COMMENT ON TABLE item_classification IS 'Stores classification metadata for grocery list items'; +COMMENT ON COLUMN item_classification.id IS 'Foreign key to grocery_list.id (one-to-one relationship)'; +COMMENT ON COLUMN item_classification.item_type IS 'High-level category (produce, meat, dairy, etc.)'; +COMMENT ON COLUMN item_classification.item_group IS 'Subcategory within item_type (filtered by type)'; +COMMENT ON COLUMN item_classification.zone IS 'Store zone/location (optional)'; +COMMENT ON COLUMN item_classification.confidence IS 'Confidence score 0-1 (1.0 for user-provided, lower for ML-predicted)'; +COMMENT ON COLUMN item_classification.source IS 'Source of classification: user, ml, or default'; diff --git a/packages/db/migrations/create_sessions_table.sql b/packages/db/migrations/create_sessions_table.sql new file mode 100644 index 0000000..b398ab5 --- /dev/null +++ b/packages/db/migrations/create_sessions_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS sessions ( + id VARCHAR(128) PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + user_agent TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +COMMENT ON TABLE sessions IS 'DB-backed application sessions'; +COMMENT ON COLUMN sessions.id IS 'Opaque session identifier stored in HttpOnly cookie'; diff --git a/packages/db/migrations/multi_household_architecture.sql b/packages/db/migrations/multi_household_architecture.sql new file mode 100644 index 0000000..b64a567 --- /dev/null +++ b/packages/db/migrations/multi_household_architecture.sql @@ -0,0 +1,397 @@ +-- ============================================================================ +-- Multi-Household & Multi-Store Architecture Migration +-- ============================================================================ +-- This migration transforms the single-list app into a multi-tenant system +-- supporting multiple households, each with multiple stores. +-- +-- IMPORTANT: Backup your database before running this migration! +-- pg_dump grocery_list > backup_$(date +%Y%m%d).sql +-- +-- Migration Strategy: +-- 1. Create new tables +-- 2. Create "Main Household" for existing users +-- 3. Migrate existing data to new structure +-- 4. Update roles (keep users.role for system admin) +-- 5. Verify data integrity +-- 6. (Manual step) Drop old tables after verification +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- STEP 1: CREATE NEW TABLES +-- ============================================================================ + +-- Households table +CREATE TABLE IF NOT EXISTS households ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + invite_code VARCHAR(20) UNIQUE NOT NULL, + code_expires_at TIMESTAMP +); + +CREATE INDEX idx_households_invite_code ON households(invite_code); +COMMENT ON TABLE households IS 'Household groups (families, roommates, etc.)'; +COMMENT ON COLUMN households.invite_code IS 'Unique code for inviting users to join household'; + +-- Store types table +CREATE TABLE IF NOT EXISTS stores ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + default_zones JSONB, + created_at TIMESTAMP DEFAULT NOW() +); + +COMMENT ON TABLE stores IS 'Store types/chains (Costco, Target, Walmart, etc.)'; +COMMENT ON COLUMN stores.default_zones IS 'JSON array of default zone names for this store type'; + +-- User-Household membership with per-household roles +CREATE TABLE IF NOT EXISTS household_members ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')), + joined_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, user_id) +); + +CREATE INDEX idx_household_members_user ON household_members(user_id); +CREATE INDEX idx_household_members_household ON household_members(household_id); +COMMENT ON TABLE household_members IS 'User membership in households with per-household roles'; +COMMENT ON COLUMN household_members.role IS 'admin: full control, user: standard member'; + +-- Household-Store relationship +CREATE TABLE IF NOT EXISTS household_stores ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + is_default BOOLEAN DEFAULT FALSE, + added_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id) +); + +CREATE INDEX idx_household_stores_household ON household_stores(household_id); +COMMENT ON TABLE household_stores IS 'Which stores each household shops at'; + +-- Master item catalog (shared across all households) +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + default_image BYTEA, + default_image_mime_type VARCHAR(50), + created_at TIMESTAMP DEFAULT NOW(), + usage_count INTEGER DEFAULT 0 +); + +CREATE INDEX idx_items_name ON items(name); +CREATE INDEX idx_items_usage_count ON items(usage_count DESC); +COMMENT ON TABLE items IS 'Master item catalog shared across all households'; +COMMENT ON COLUMN items.usage_count IS 'Popularity metric for suggestions'; + +-- Household-specific grocery lists (per store) +CREATE TABLE IF NOT EXISTS household_lists ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + item_id INTEGER REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL DEFAULT 1, + bought BOOLEAN DEFAULT FALSE, + custom_image BYTEA, + custom_image_mime_type VARCHAR(50), + added_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + modified_on TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id, item_id) +); + +CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id); +CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought); +CREATE INDEX idx_household_lists_modified ON household_lists(modified_on DESC); +COMMENT ON TABLE household_lists IS 'Grocery lists scoped to household + store combination'; + +-- Household-specific item classifications (per store) +CREATE TABLE IF NOT EXISTS household_item_classifications ( + id SERIAL PRIMARY KEY, + household_id INTEGER REFERENCES households(id) ON DELETE CASCADE, + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + item_id INTEGER REFERENCES items(id) ON DELETE CASCADE, + item_type VARCHAR(50), + item_group VARCHAR(100), + zone VARCHAR(100), + confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1), + source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(household_id, store_id, item_id) +); + +CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id); +CREATE INDEX idx_household_classifications_type ON household_item_classifications(item_type); +CREATE INDEX idx_household_classifications_zone ON household_item_classifications(zone); +COMMENT ON TABLE household_item_classifications IS 'Item classifications scoped to household + store'; + +-- History tracking +CREATE TABLE IF NOT EXISTS household_list_history ( + id SERIAL PRIMARY KEY, + household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL, + added_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + added_on TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_household_history_list ON household_list_history(household_list_id); +CREATE INDEX idx_household_history_user ON household_list_history(added_by); +CREATE INDEX idx_household_history_date ON household_list_history(added_on DESC); +COMMENT ON TABLE household_list_history IS 'Tracks who added items and when'; + +-- ============================================================================ +-- STEP 2: CREATE DEFAULT HOUSEHOLD AND STORE +-- ============================================================================ + +-- Create default household for existing users +INSERT INTO households (name, created_by, invite_code) +SELECT + 'Main Household', + (SELECT id FROM users WHERE role = 'admin' LIMIT 1), -- First admin as creator + 'MAIN' || LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0') -- Random 6-digit code +WHERE NOT EXISTS (SELECT 1 FROM households WHERE name = 'Main Household'); + +-- Create default Costco store +INSERT INTO stores (name, default_zones) +VALUES ( + 'Costco', + '{ + "zones": [ + "Entrance & Seasonal", + "Fresh Produce", + "Meat & Seafood", + "Dairy & Refrigerated", + "Deli & Prepared Foods", + "Bakery & Bread", + "Frozen Foods", + "Beverages", + "Snacks & Candy", + "Pantry & Dry Goods", + "Health & Beauty", + "Household & Cleaning", + "Other" + ] + }'::jsonb +) +ON CONFLICT (name) DO NOTHING; + +-- Link default household to default store +INSERT INTO household_stores (household_id, store_id, is_default) +SELECT + (SELECT id FROM households WHERE name = 'Main Household'), + (SELECT id FROM stores WHERE name = 'Costco'), + TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM household_stores + WHERE household_id = (SELECT id FROM households WHERE name = 'Main Household') +); + +-- ============================================================================ +-- STEP 3: MIGRATE USERS TO HOUSEHOLD MEMBERS +-- ============================================================================ + +-- Add all existing users to Main Household +-- Old admins become household admins, others become standard users +INSERT INTO household_members (household_id, user_id, role) +SELECT + (SELECT id FROM households WHERE name = 'Main Household'), + id, + CASE + WHEN role = 'admin' THEN 'admin' + ELSE 'user' + END +FROM users +WHERE NOT EXISTS ( + SELECT 1 FROM household_members hm + WHERE hm.user_id = users.id + AND hm.household_id = (SELECT id FROM households WHERE name = 'Main Household') +); + +-- ============================================================================ +-- STEP 4: MIGRATE ITEMS TO MASTER CATALOG +-- ============================================================================ + +-- Extract unique items from grocery_list into master items table +INSERT INTO items (name, default_image, default_image_mime_type, created_at, usage_count) +SELECT + LOWER(TRIM(item_name)) as name, + item_image, + image_mime_type, + MIN(modified_on) as created_at, + COUNT(*) as usage_count +FROM grocery_list +WHERE NOT EXISTS ( + SELECT 1 FROM items WHERE LOWER(items.name) = LOWER(TRIM(grocery_list.item_name)) +) +GROUP BY LOWER(TRIM(item_name)), item_image, image_mime_type +ON CONFLICT (name) DO NOTHING; + +-- ============================================================================ +-- STEP 5: MIGRATE GROCERY_LIST TO HOUSEHOLD_LISTS +-- ============================================================================ + +-- Migrate current list to household_lists +INSERT INTO household_lists ( + household_id, + store_id, + item_id, + quantity, + bought, + custom_image, + custom_image_mime_type, + added_by, + modified_on +) +SELECT + (SELECT id FROM households WHERE name = 'Main Household'), + (SELECT id FROM stores WHERE name = 'Costco'), + i.id, + gl.quantity, + gl.bought, + CASE WHEN gl.item_image != i.default_image THEN gl.item_image ELSE NULL END, -- Only store if different + CASE WHEN gl.item_image != i.default_image THEN gl.image_mime_type ELSE NULL END, + gl.added_by, + gl.modified_on +FROM grocery_list gl +JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name)) +WHERE NOT EXISTS ( + SELECT 1 FROM household_lists hl + WHERE hl.household_id = (SELECT id FROM households WHERE name = 'Main Household') + AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco') + AND hl.item_id = i.id +) +ON CONFLICT (household_id, store_id, item_id) DO NOTHING; + +-- ============================================================================ +-- STEP 6: MIGRATE ITEM_CLASSIFICATION TO HOUSEHOLD_ITEM_CLASSIFICATIONS +-- ============================================================================ + +-- Migrate classifications +INSERT INTO household_item_classifications ( + household_id, + store_id, + item_id, + item_type, + item_group, + zone, + confidence, + source, + created_at, + updated_at +) +SELECT + (SELECT id FROM households WHERE name = 'Main Household'), + (SELECT id FROM stores WHERE name = 'Costco'), + i.id, + ic.item_type, + ic.item_group, + ic.zone, + ic.confidence, + ic.source, + ic.created_at, + ic.updated_at +FROM item_classification ic +JOIN grocery_list gl ON ic.id = gl.id +JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name)) +WHERE NOT EXISTS ( + SELECT 1 FROM household_item_classifications hic + WHERE hic.household_id = (SELECT id FROM households WHERE name = 'Main Household') + AND hic.store_id = (SELECT id FROM stores WHERE name = 'Costco') + AND hic.item_id = i.id +) +ON CONFLICT (household_id, store_id, item_id) DO NOTHING; + +-- ============================================================================ +-- STEP 7: MIGRATE GROCERY_HISTORY TO HOUSEHOLD_LIST_HISTORY +-- ============================================================================ + +-- Migrate history records +INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on) +SELECT + hl.id, + gh.quantity, + gh.added_by, + gh.added_on +FROM grocery_history gh +JOIN grocery_list gl ON gh.list_item_id = gl.id +JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name)) +JOIN household_lists hl ON hl.item_id = i.id + AND hl.household_id = (SELECT id FROM households WHERE name = 'Main Household') + AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco') +WHERE NOT EXISTS ( + SELECT 1 FROM household_list_history hlh + WHERE hlh.household_list_id = hl.id + AND hlh.added_by = gh.added_by + AND hlh.added_on = gh.added_on +); + +-- ============================================================================ +-- STEP 8: UPDATE USER ROLES (SYSTEM-WIDE) +-- ============================================================================ + +-- Update system roles: admin → system_admin, others → user +UPDATE users +SET role = 'system_admin' +WHERE role = 'admin'; + +UPDATE users +SET role = 'user' +WHERE role IN ('editor', 'viewer'); + +-- ============================================================================ +-- VERIFICATION QUERIES +-- ============================================================================ + +-- Run these to verify migration success: + +-- Check household created +-- SELECT * FROM households; + +-- Check all users added to household +-- SELECT u.username, u.role as system_role, hm.role as household_role +-- FROM users u +-- JOIN household_members hm ON u.id = hm.user_id +-- ORDER BY u.id; + +-- Check items migrated +-- SELECT COUNT(*) as total_items FROM items; +-- SELECT COUNT(*) as original_items FROM (SELECT DISTINCT item_name FROM grocery_list) sub; + +-- Check lists migrated +-- SELECT COUNT(*) as new_lists FROM household_lists; +-- SELECT COUNT(*) as old_lists FROM grocery_list; + +-- Check classifications migrated +-- SELECT COUNT(*) as new_classifications FROM household_item_classifications; +-- SELECT COUNT(*) as old_classifications FROM item_classification; + +-- Check history migrated +-- SELECT COUNT(*) as new_history FROM household_list_history; +-- SELECT COUNT(*) as old_history FROM grocery_history; + +-- ============================================================================ +-- MANUAL STEPS AFTER VERIFICATION +-- ============================================================================ + +-- After verifying data integrity, uncomment and run these to clean up: + +-- DROP TABLE IF EXISTS grocery_history CASCADE; +-- DROP TABLE IF EXISTS item_classification CASCADE; +-- DROP TABLE IF EXISTS grocery_list CASCADE; + +COMMIT; + +-- ============================================================================ +-- ROLLBACK (if something goes wrong) +-- ============================================================================ + +-- ROLLBACK; + +-- Then restore from backup: +-- psql -U your_user -d grocery_list < backup_YYYYMMDD.sql diff --git a/packages/db/migrations/stale-files.json b/packages/db/migrations/stale-files.json new file mode 100644 index 0000000..7ceea5d --- /dev/null +++ b/packages/db/migrations/stale-files.json @@ -0,0 +1,12 @@ +{ + "stale_files": [ + "add_display_name_column.sql", + "add_image_columns.sql", + "add_modified_on_column.sql", + "add_notes_column.sql", + "create_item_classification_table.sql", + "create_sessions_table.sql", + "multi_household_architecture.sql", + "zz_group_invites_and_join_policies.sql" + ] +} diff --git a/packages/db/migrations/zz_group_invites_and_join_policies.sql b/packages/db/migrations/zz_group_invites_and_join_policies.sql new file mode 100644 index 0000000..7d54e76 --- /dev/null +++ b/packages/db/migrations/zz_group_invites_and_join_policies.sql @@ -0,0 +1,165 @@ +BEGIN; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'group_join_policy') THEN + CREATE TYPE group_join_policy AS ENUM ( + 'NOT_ACCEPTING', + 'AUTO_ACCEPT', + 'APPROVAL_REQUIRED' + ); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'group_join_request_status') THEN + CREATE TYPE group_join_request_status AS ENUM ( + 'PENDING', + 'APPROVED', + 'DENIED', + 'CANCELED' + ); + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS group_settings ( + group_id INTEGER PRIMARY KEY REFERENCES households(id) ON DELETE CASCADE, + join_policy group_join_policy NOT NULL DEFAULT 'NOT_ACCEPTING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO group_settings (group_id, join_policy) +SELECT h.id, 'NOT_ACCEPTING'::group_join_policy +FROM households h +WHERE NOT EXISTS ( + SELECT 1 FROM group_settings gs WHERE gs.group_id = h.id +); + +CREATE TABLE IF NOT EXISTS group_join_requests ( + id SERIAL PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status group_join_request_status NOT NULL DEFAULT 'PENDING', + decided_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + decided_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_group_join_requests_pending + ON group_join_requests(group_id, user_id) + WHERE status = 'PENDING'; + +CREATE INDEX IF NOT EXISTS idx_group_join_requests_group + ON group_join_requests(group_id); + +CREATE TABLE IF NOT EXISTS group_invite_links ( + id SERIAL PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(64) NOT NULL UNIQUE, + policy group_join_policy NOT NULL DEFAULT 'NOT_ACCEPTING', + single_use BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_group_invite_links_group_id + ON group_invite_links(group_id); + +CREATE TABLE IF NOT EXISTS group_audit_log ( + id SERIAL PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + actor_role VARCHAR(20), + event_type VARCHAR(100) NOT NULL, + request_id VARCHAR(128) NOT NULL, + ip INET, + user_agent TEXT, + success BOOLEAN NOT NULL DEFAULT TRUE, + error_code VARCHAR(100), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_group_audit_group_created + ON group_audit_log(group_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_group_audit_request_id + ON group_audit_log(request_id); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'households' + AND indexdef ILIKE 'CREATE UNIQUE INDEX%' + AND indexdef ILIKE '%(invite_code)%' + ) THEN + CREATE UNIQUE INDEX idx_households_invite_code_unique + ON households(invite_code); + END IF; +END +$$; + +ALTER TABLE household_members +DROP CONSTRAINT IF EXISTS household_members_role_check; + +UPDATE household_members +SET role = 'member' +WHERE role = 'user'; + +WITH ranked_admins AS ( + SELECT + hm.id, + ROW_NUMBER() OVER ( + PARTITION BY hm.household_id + ORDER BY hm.joined_at ASC, hm.id ASC + ) AS admin_rank + FROM household_members hm + WHERE hm.role = 'admin' +) +UPDATE household_members hm +SET role = CASE + WHEN ra.admin_rank = 1 THEN 'owner' + ELSE 'admin' +END +FROM ranked_admins ra +WHERE hm.id = ra.id; + +WITH ownerless_households AS ( + SELECT h.id AS household_id + FROM households h + WHERE NOT EXISTS ( + SELECT 1 + FROM household_members hm + WHERE hm.household_id = h.id + AND hm.role = 'owner' + ) +), +first_member AS ( + SELECT DISTINCT ON (hm.household_id) + hm.id, + hm.household_id + FROM household_members hm + JOIN ownerless_households oh ON oh.household_id = hm.household_id + ORDER BY hm.household_id, hm.joined_at ASC, hm.id ASC +) +UPDATE household_members hm +SET role = 'owner' +FROM first_member fm +WHERE hm.id = fm.id; + +ALTER TABLE household_members +ADD CONSTRAINT household_members_role_check +CHECK (role IN ('owner', 'admin', 'member')); + +COMMIT; diff --git a/rebuild-dev.sh b/rebuild-dev.sh new file mode 100644 index 0000000..baf28f2 --- /dev/null +++ b/rebuild-dev.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/docker-compose.dev.yml" + +find_compose_cmd() { + if command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD=(docker-compose) + return + fi + + if command -v docker >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) + return + fi + + echo "Docker Compose not found. Install docker-compose or Docker Desktop first." + exit 1 +} + +main() { + find_compose_cmd + + echo "Stopping containers and removing volumes..." + "${COMPOSE_CMD[@]}" -f "$COMPOSE_FILE" down -v + + echo "Rebuilding and starting containers..." + "${COMPOSE_CMD[@]}" -f "$COMPOSE_FILE" up --build +} + +main "$@" diff --git a/run-migration.bat b/run-migration.bat new file mode 100644 index 0000000..993cab1 --- /dev/null +++ b/run-migration.bat @@ -0,0 +1,21 @@ +@echo off +setlocal + +if "%DATABASE_URL%"=="" ( + echo DATABASE_URL is required. Aborting. + exit /b 1 +) + +echo Checking migration status... +call npm run db:migrate:status +if errorlevel 1 exit /b 1 + +echo Applying pending migrations... +call npm run db:migrate +if errorlevel 1 exit /b 1 + +echo Final migration status... +call npm run db:migrate:status +if errorlevel 1 exit /b 1 + +echo Done. diff --git a/run-migration.sh b/run-migration.sh new file mode 100644 index 0000000..48f0d90 --- /dev/null +++ b/run-migration.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +set -euo pipefail + +if ! command -v node >/dev/null 2>&1; then + echo "node is required." + exit 1 +fi + +if [ -z "${DATABASE_URL:-}" ]; then + echo "DATABASE_URL is required. Aborting." + exit 1 +fi + +echo "Checking migration status..." +npm run db:migrate:status + +echo "Applying pending migrations..." +npm run db:migrate + +echo "Final migration status..." +npm run db:migrate:status + +echo "Done." diff --git a/scripts/db-migrate-common.js b/scripts/db-migrate-common.js new file mode 100644 index 0000000..3158941 --- /dev/null +++ b/scripts/db-migrate-common.js @@ -0,0 +1,153 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const migrationsDir = path.resolve( + __dirname, + "..", + "packages", + "db", + "migrations" +); +const staleConfigPath = path.join(migrationsDir, "stale-files.json"); + +function readStaleConfigFile() { + if (!fs.existsSync(staleConfigPath)) { + return new Set(); + } + + const raw = fs.readFileSync(staleConfigPath, "utf8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid JSON in ${staleConfigPath}`); + } + + const values = Array.isArray(parsed?.stale_files) ? parsed.stale_files : []; + return new Set( + values + .map((value) => String(value || "").trim()) + .filter(Boolean) + ); +} + +function getSkippedMigrations() { + const includeStale = String(process.env.DB_MIGRATE_INCLUDE_STALE || "") + .trim() + .toLowerCase(); + const skipFromConfig = + includeStale === "1" || includeStale === "true" || includeStale === "yes" + ? new Set() + : readStaleConfigFile(); + + const raw = process.env.DB_MIGRATE_SKIP_FILES || ""; + const skipFromEnv = new Set( + raw + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + ); + + return new Set([...skipFromConfig, ...skipFromEnv]); +} + +function ensureDatabaseUrl() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error("DATABASE_URL is required."); + } + return databaseUrl; +} + +function ensurePsql() { + const result = spawnSync("psql", ["--version"], { stdio: "pipe" }); + if (result.error || result.status !== 0) { + throw new Error("psql executable was not found in PATH."); + } +} + +function ensureMigrationsDir() { + if (!fs.existsSync(migrationsDir)) { + throw new Error(`Migrations directory not found: ${migrationsDir}`); + } +} + +function getMigrationFiles() { + ensureMigrationsDir(); + const skipped = getSkippedMigrations(); + return fs + .readdirSync(migrationsDir) + .filter((file) => file.endsWith(".sql")) + .filter((file) => !skipped.has(file)) + .sort((a, b) => a.localeCompare(b)); +} + +function runPsql(databaseUrl, args) { + const result = spawnSync("psql", [databaseUrl, ...args], { + stdio: "pipe", + encoding: "utf8", + }); + if (result.status !== 0) { + const stderr = (result.stderr || "").trim(); + const stdout = (result.stdout || "").trim(); + const details = [stderr, stdout].filter(Boolean).join("\n"); + throw new Error(details || "psql command failed"); + } + return result.stdout || ""; +} + +function escapeSqlLiteral(value) { + return value.replace(/'/g, "''"); +} + +function ensureSchemaMigrationsTable(databaseUrl) { + runPsql(databaseUrl, [ + "-v", + "ON_ERROR_STOP=1", + "-c", + "CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW());", + ]); +} + +function getAppliedMigrations(databaseUrl) { + const output = runPsql(databaseUrl, [ + "-At", + "-v", + "ON_ERROR_STOP=1", + "-c", + "SELECT filename FROM schema_migrations ORDER BY filename ASC;", + ]); + return new Set( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + ); +} + +function applyMigration(databaseUrl, filename) { + const fullPath = path.join(migrationsDir, filename); + runPsql(databaseUrl, ["-v", "ON_ERROR_STOP=1", "-f", fullPath]); + runPsql(databaseUrl, [ + "-v", + "ON_ERROR_STOP=1", + "-c", + `INSERT INTO schema_migrations (filename) VALUES ('${escapeSqlLiteral( + filename + )}') ON CONFLICT DO NOTHING;`, + ]); +} + +module.exports = { + applyMigration, + ensureDatabaseUrl, + ensurePsql, + ensureSchemaMigrationsTable, + getAppliedMigrations, + getMigrationFiles, + getSkippedMigrations, + migrationsDir, +}; diff --git a/scripts/db-migrate-new.js b/scripts/db-migrate-new.js new file mode 100644 index 0000000..a02439a --- /dev/null +++ b/scripts/db-migrate-new.js @@ -0,0 +1,69 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { migrationsDir } = require("./db-migrate-common"); + +function sanitizeName(input) { + return String(input || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +function timestampUtc() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, "0"); + return [ + now.getUTCFullYear(), + pad(now.getUTCMonth() + 1), + pad(now.getUTCDate()), + "_", + pad(now.getUTCHours()), + pad(now.getUTCMinutes()), + pad(now.getUTCSeconds()), + ].join(""); +} + +function main() { + const rawName = process.argv.slice(2).join(" ").trim(); + if (!rawName || process.argv.includes("--help")) { + console.log("Usage: npm run db:migrate:new -- "); + process.exit(rawName ? 0 : 1); + } + + const name = sanitizeName(rawName); + if (!name) { + throw new Error("Migration name must contain letters or numbers."); + } + + if (!fs.existsSync(migrationsDir)) { + throw new Error(`Migrations directory not found: ${migrationsDir}`); + } + + const filename = `${timestampUtc()}_${name}.sql`; + const fullPath = path.join(migrationsDir, filename); + if (fs.existsSync(fullPath)) { + throw new Error(`Migration already exists: ${filename}`); + } + + const template = [ + "BEGIN;", + "", + "-- Add schema changes here.", + "", + "COMMIT;", + "", + ].join("\n"); + + fs.writeFileSync(fullPath, template, "utf8"); + console.log(`Created migration: ${path.relative(process.cwd(), fullPath)}`); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/scripts/db-migrate-status.js b/scripts/db-migrate-status.js new file mode 100644 index 0000000..135e0ab --- /dev/null +++ b/scripts/db-migrate-status.js @@ -0,0 +1,42 @@ +"use strict"; + +const { + ensureDatabaseUrl, + ensurePsql, + ensureSchemaMigrationsTable, + getAppliedMigrations, + getMigrationFiles, +} = require("./db-migrate-common"); + +function main() { + if (process.argv.includes("--help")) { + console.log("Usage: npm run db:migrate:status"); + process.exit(0); + } + + const databaseUrl = ensureDatabaseUrl(); + ensurePsql(); + ensureSchemaMigrationsTable(databaseUrl); + + const files = getMigrationFiles(); + const applied = getAppliedMigrations(databaseUrl); + + let pendingCount = 0; + for (const file of files) { + const status = applied.has(file) ? "APPLIED" : "PENDING"; + if (status === "PENDING") pendingCount += 1; + console.log(`${status} ${file}`); + } + + console.log(""); + console.log(`Total: ${files.length}`); + console.log(`Applied: ${files.length - pendingCount}`); + console.log(`Pending: ${pendingCount}`); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/scripts/db-migrate-verify.js b/scripts/db-migrate-verify.js new file mode 100644 index 0000000..06f636d --- /dev/null +++ b/scripts/db-migrate-verify.js @@ -0,0 +1,41 @@ +"use strict"; + +const { + ensureDatabaseUrl, + ensurePsql, + ensureSchemaMigrationsTable, + getAppliedMigrations, + getMigrationFiles, +} = require("./db-migrate-common"); + +function main() { + if (process.argv.includes("--help")) { + console.log("Usage: npm run db:migrate:verify"); + process.exit(0); + } + + const databaseUrl = ensureDatabaseUrl(); + ensurePsql(); + ensureSchemaMigrationsTable(databaseUrl); + + const files = getMigrationFiles(); + const applied = getAppliedMigrations(databaseUrl); + const pending = files.filter((file) => !applied.has(file)); + + if (pending.length > 0) { + console.error("Pending migrations detected:"); + for (const file of pending) { + console.error(`- ${file}`); + } + process.exit(1); + } + + console.log("Migration verification passed. No pending migrations."); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/scripts/db-migrate.js b/scripts/db-migrate.js new file mode 100644 index 0000000..d7de7d9 --- /dev/null +++ b/scripts/db-migrate.js @@ -0,0 +1,52 @@ +"use strict"; + +const { + applyMigration, + ensureDatabaseUrl, + ensurePsql, + ensureSchemaMigrationsTable, + getAppliedMigrations, + getMigrationFiles, +} = require("./db-migrate-common"); + +function main() { + if (process.argv.includes("--help")) { + console.log("Usage: npm run db:migrate"); + process.exit(0); + } + + const migrateDisabled = String(process.env.DB_MIGRATE_DISABLE || "") + .trim() + .toLowerCase(); + if (migrateDisabled === "1" || migrateDisabled === "true" || migrateDisabled === "yes") { + console.log("DB migrations are disabled by DB_MIGRATE_DISABLE. Skipping."); + return; + } + + const databaseUrl = ensureDatabaseUrl(); + ensurePsql(); + ensureSchemaMigrationsTable(databaseUrl); + + const files = getMigrationFiles(); + const applied = getAppliedMigrations(databaseUrl); + const pending = files.filter((file) => !applied.has(file)); + + if (pending.length === 0) { + console.log("No pending migrations."); + return; + } + + for (const file of pending) { + console.log(`Applying: ${file}`); + applyMigration(databaseUrl, file); + } + + console.log(`Applied ${pending.length} migration(s).`); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/scripts/db-stale-sql-tracker.js b/scripts/db-stale-sql-tracker.js new file mode 100644 index 0000000..8714ed6 --- /dev/null +++ b/scripts/db-stale-sql-tracker.js @@ -0,0 +1,212 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const repoRoot = path.resolve(__dirname, ".."); +const canonicalDir = path.resolve(repoRoot, "packages", "db", "migrations"); +const legacyDir = path.resolve(repoRoot, "backend", "migrations"); +const defaultReportPath = path.resolve(legacyDir, "stale-sql-report.json"); + +function parseArgs(argv) { + const args = new Set(argv); + return { + write: args.has("--write"), + failOnStale: args.has("--fail-on-stale"), + help: args.has("--help"), + }; +} + +function ensureDirectoryExists(dirPath, label) { + if (!fs.existsSync(dirPath)) { + throw new Error(`${label} directory not found: ${dirPath}`); + } +} + +function sha256File(filePath) { + const hash = crypto.createHash("sha256"); + hash.update(fs.readFileSync(filePath)); + return hash.digest("hex"); +} + +function sha256Text(value) { + const hash = crypto.createHash("sha256"); + hash.update(value); + return hash.digest("hex"); +} + +function comparableSql(filePath) { + return fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").trimEnd() + "\n"; +} + +function listFiles(dirPath) { + return fs + .readdirSync(dirPath) + .filter((name) => fs.statSync(path.join(dirPath, name)).isFile()) + .sort((a, b) => a.localeCompare(b)); +} + +function listSqlFiles(dirPath) { + return listFiles(dirPath).filter((name) => name.toLowerCase().endsWith(".sql")); +} + +function mapByNameWithHash(dirPath, names) { + const map = new Map(); + for (const name of names) { + map.set(name, { + name, + path: path.join(dirPath, name), + sha256: sha256File(path.join(dirPath, name)), + normalized_sha256: sha256Text(comparableSql(path.join(dirPath, name))), + }); + } + return map; +} + +function buildReport() { + ensureDirectoryExists(canonicalDir, "Canonical migrations"); + ensureDirectoryExists(legacyDir, "Legacy migrations"); + + const canonicalSql = listSqlFiles(canonicalDir); + const legacySql = listSqlFiles(legacyDir); + const legacyNonSql = listFiles(legacyDir).filter( + (name) => !name.toLowerCase().endsWith(".sql") + ); + + const canonicalMap = mapByNameWithHash(canonicalDir, canonicalSql); + const legacyMap = mapByNameWithHash(legacyDir, legacySql); + + const staleFiles = []; + for (const legacyName of legacySql) { + const legacyFile = legacyMap.get(legacyName); + const canonicalFile = canonicalMap.get(legacyName); + + if (!canonicalFile) { + staleFiles.push({ + filename: legacyName, + status: "STALE_ONLY_IN_BACKEND", + requires_action: true, + backend_sha256: legacyFile.sha256, + }); + continue; + } + + if (legacyFile.normalized_sha256 === canonicalFile.normalized_sha256) { + staleFiles.push({ + filename: legacyName, + status: "STALE_DUPLICATE_OF_CANONICAL", + requires_action: false, + backend_sha256: legacyFile.sha256, + canonical_sha256: canonicalFile.sha256, + normalized_sha256: legacyFile.normalized_sha256, + }); + continue; + } + + staleFiles.push({ + filename: legacyName, + status: "STALE_DIVERGED_FROM_CANONICAL", + requires_action: true, + backend_sha256: legacyFile.sha256, + canonical_sha256: canonicalFile.sha256, + backend_normalized_sha256: legacyFile.normalized_sha256, + canonical_normalized_sha256: canonicalFile.normalized_sha256, + }); + } + + const canonicalOnly = canonicalSql + .filter((name) => !legacyMap.has(name)) + .map((name) => ({ + filename: name, + status: "CANONICAL_ONLY", + requires_action: false, + canonical_sha256: canonicalMap.get(name).sha256, + })); + + const actionRequired = staleFiles.filter((file) => file.requires_action); + + return { + generated_at: new Date().toISOString(), + canonical_dir: path.relative(repoRoot, canonicalDir), + legacy_dir: path.relative(repoRoot, legacyDir), + stale_sql_files: staleFiles, + canonical_only_sql_files: canonicalOnly, + legacy_non_sql_files: legacyNonSql, + summary: { + stale_total: staleFiles.length, + stale_only_in_backend_total: staleFiles.filter( + (f) => f.status === "STALE_ONLY_IN_BACKEND" + ).length, + stale_duplicate_total: staleFiles.filter( + (f) => f.status === "STALE_DUPLICATE_OF_CANONICAL" + ).length, + stale_diverged_total: staleFiles.filter( + (f) => f.status === "STALE_DIVERGED_FROM_CANONICAL" + ).length, + action_required_total: actionRequired.length, + canonical_only_total: canonicalOnly.length, + }, + }; +} + +function printReport(report) { + console.log("Stale SQL Tracker"); + console.log(`- Canonical: ${report.canonical_dir}`); + console.log(`- Legacy: ${report.legacy_dir}`); + console.log(`- Generated: ${report.generated_at}`); + console.log(""); + + console.log(`Legacy SQL files in reference dir: ${report.summary.stale_total}`); + for (const stale of report.stale_sql_files) { + const actionText = stale.requires_action ? "action required" : "reference only"; + console.log(` - ${stale.filename} :: ${stale.status} (${actionText})`); + } + + console.log(""); + console.log(`Action-required legacy SQL files: ${report.summary.action_required_total}`); + + console.log(""); + console.log(`Canonical-only SQL files: ${report.summary.canonical_only_total}`); + for (const canonicalOnly of report.canonical_only_sql_files) { + console.log(` - ${canonicalOnly.filename}`); + } + + console.log(""); + console.log(`Legacy non-SQL files: ${report.legacy_non_sql_files.length}`); + for (const nonSql of report.legacy_non_sql_files) { + console.log(` - ${nonSql}`); + } +} + +function writeReport(report) { + fs.writeFileSync(defaultReportPath, JSON.stringify(report, null, 2) + "\n", "utf8"); + console.log(""); + console.log(`Wrote stale SQL report: ${path.relative(repoRoot, defaultReportPath)}`); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log("Usage: node scripts/db-stale-sql-tracker.js [--write] [--fail-on-stale]"); + process.exit(0); + } + + const report = buildReport(); + printReport(report); + + if (options.write) { + writeReport(report); + } + + if (options.failOnStale && report.summary.action_required_total > 0) { + process.exit(1); + } +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +}