commit 7afca6561f8e79eff6f2a8f720f9a5a1a1000e33 Author: Kroonk Date: Fri May 22 17:32:24 2026 +0200 feat: add Misch & Friends companion app diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8b3ec30 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +PORT=38110 +MONICA_BASE_URL=https://crm.mischlabs.de +MONICA_API_TOKEN= +DATA_DIR=./data diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml new file mode 100644 index 0000000..230fedf --- /dev/null +++ b/.gitea/workflows/docker-build.yml @@ -0,0 +1,52 @@ +name: Build & Push Friends Image to Gitea Registry + +on: + push: + branches: [main] + +env: + REGISTRY: git.mischlabs.de + REGISTRY_USER: MrDiderot + REGISTRY_IMAGE: git.mischlabs.de/mrdiderot/friends + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Docker CLI + shell: sh + run: | + if ! command -v docker >/dev/null 2>&1; then + echo "Docker CLI not found. Installing static binary..." + if command -v curl >/dev/null 2>&1; then + curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker + elif command -v wget >/dev/null 2>&1; then + wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker + else + echo "Neither curl nor wget found!" + exit 1 + fi + fi + docker --version + + - name: Log in to Gitea Package Registry + shell: sh + run: | + if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then + echo "Missing REGISTRY_TOKEN secret." + exit 1 + fi + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ env.REGISTRY_USER }}" --password-stdin + + - name: Build and push Docker image + shell: sh + run: | + docker build \ + -t ${{ env.REGISTRY_IMAGE }}:latest \ + -t ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} \ + . + docker push ${{ env.REGISTRY_IMAGE }}:latest + docker push ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dc9113 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +node_modules/ +data/ +npm-debug.log* +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb92548 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY server.js ./ +COPY public ./public + +ENV NODE_ENV=production +ENV PORT=38110 + +EXPOSE 38110 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..99ee163 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Misch & Friends + +Friends is the voice-first MischLabs companion for Monica CRM. + +Monica stays the archive. Friends is the daily surface: + +- dictate or type what happened +- review the structured result +- save journal entries, contact notes, and reminders +- write to Monica when `MONICA_API_TOKEN` is configured + +## Local Start + +```bash +cp .env.example .env +npm install +npm start +``` + +Open: + +```text +http://localhost:38110 +``` + +Public domain: + +```text +https://friends.mischlabs.de +``` + +## Environment + +```env +PORT=38110 +MONICA_BASE_URL=https://crm.mischlabs.de +MONICA_API_TOKEN= +DATA_DIR=./data +``` + +Without `MONICA_API_TOKEN`, Friends runs in demo/local mode and stores entries in `data/entries.json`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ae66ed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + friends: + image: git.mischlabs.de/mrdiderot/friends:latest + container_name: friends + restart: unless-stopped + env_file: + - .env + ports: + - "${FRIENDS_PORT:-38110}:38110" + volumes: + - friends_data:/app/data + +volumes: + friends_data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b875611 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,840 @@ +{ + "name": "friends", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "friends", + "version": "0.1.0", + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.19.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "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.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f25bd0a --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "friends", + "version": "0.1.0", + "private": true, + "description": "Misch & Friends voice-first companion interface for Monica CRM.", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.19.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..5aa90d5 --- /dev/null +++ b/public/app.js @@ -0,0 +1,298 @@ +const state = { + mode: "auto", + analysis: null, + contacts: [], + recognition: null, + listening: false, +}; + +const elements = { + apiStatus: document.querySelector("#apiStatus"), + inputText: document.querySelector("#inputText"), + analyzeButton: document.querySelector("#analyzeButton"), + clearButton: document.querySelector("#clearButton"), + voiceButton: document.querySelector("#voiceButton"), + review: document.querySelector("#review"), + writeToMonica: document.querySelector("#writeToMonica"), + contactSearch: document.querySelector("#contactSearch"), + contactSelect: document.querySelector("#contactSelect"), + typeSelect: document.querySelector("#typeSelect"), + summaryInput: document.querySelector("#summaryInput"), + noteEnabled: document.querySelector("#noteEnabled"), + noteBody: document.querySelector("#noteBody"), + journalEnabled: document.querySelector("#journalEnabled"), + journalTitle: document.querySelector("#journalTitle"), + journalPost: document.querySelector("#journalPost"), + reminders: document.querySelector("#reminders"), + reminderTemplate: document.querySelector("#reminderTemplate"), + addReminder: document.querySelector("#addReminder"), + saveButton: document.querySelector("#saveButton"), + discardButton: document.querySelector("#discardButton"), + messageLog: document.querySelector("#messageLog"), +}; + +document.querySelectorAll("[data-mode]").forEach((button) => { + button.addEventListener("click", () => { + state.mode = button.dataset.mode; + document.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button)); + }); +}); + +elements.analyzeButton.addEventListener("click", analyzeInput); +elements.clearButton.addEventListener("click", () => { + elements.inputText.value = ""; + elements.review.hidden = true; + log(""); +}); +elements.discardButton.addEventListener("click", () => { + elements.review.hidden = true; + state.analysis = null; +}); +elements.addReminder.addEventListener("click", () => addReminderRow()); +elements.saveButton.addEventListener("click", saveEntry); +elements.contactSearch.addEventListener("input", debounce(loadContacts, 240)); +elements.voiceButton.addEventListener("click", toggleSpeech); + +await boot(); + +async function boot() { + await checkHealth(); + await loadContacts(); + setupSpeechRecognition(); +} + +async function checkHealth() { + try { + const health = await api("/api/health"); + elements.apiStatus.textContent = health.monicaConfigured + ? `Monica verbunden: ${health.monicaBaseUrl}` + : "Demo-Modus: Monica Token fehlt"; + elements.writeToMonica.disabled = !health.monicaConfigured; + } catch (error) { + elements.apiStatus.textContent = "Backend nicht erreichbar"; + log(error.message, "error"); + } +} + +async function loadContacts() { + const query = elements.contactSearch.value.trim(); + try { + const payload = await api(`/api/contacts${query ? `?query=${encodeURIComponent(query)}` : ""}`); + state.contacts = payload.data || []; + renderContacts(); + } catch (error) { + log(error.message, "error"); + } +} + +function renderContacts(selectedName = "") { + elements.contactSelect.innerHTML = ""; + const empty = new Option("Kein Kontakt ausgewählt", ""); + elements.contactSelect.append(empty); + + for (const contact of state.contacts) { + const option = new Option(contact.name, contact.id); + option.dataset.name = contact.name; + if (selectedName && contact.name.toLowerCase().includes(selectedName.toLowerCase())) { + option.selected = true; + } + elements.contactSelect.append(option); + } +} + +async function analyzeInput() { + const text = elements.inputText.value.trim(); + if (!text) { + log("Erst etwas diktieren oder eintippen."); + return; + } + + elements.analyzeButton.disabled = true; + try { + const analysis = await api("/api/analyze", { + method: "POST", + body: JSON.stringify({ text, mode: state.mode }), + }); + state.analysis = analysis; + renderAnalysis(analysis); + log("Ich habe daraus einen Vorschlag gemacht. Bitte kurz prüfen."); + } catch (error) { + log(error.message, "error"); + } finally { + elements.analyzeButton.disabled = false; + } +} + +function renderAnalysis(analysis) { + elements.review.hidden = false; + elements.typeSelect.value = analysis.type; + elements.summaryInput.value = analysis.summary; + elements.noteEnabled.checked = Boolean(analysis.note?.enabled); + elements.noteBody.value = analysis.note?.body || ""; + elements.journalEnabled.checked = Boolean(analysis.journal?.enabled); + elements.journalTitle.value = analysis.journal?.title || ""; + elements.journalPost.value = analysis.journal?.post || ""; + elements.reminders.innerHTML = ""; + + const inferredContact = analysis.contact?.name || ""; + if (inferredContact) { + elements.contactSearch.value = inferredContact; + loadContacts().then(() => renderContacts(inferredContact)); + } + + for (const reminder of analysis.reminders || []) { + addReminderRow(reminder); + } +} + +function addReminderRow(reminder = {}) { + const fragment = elements.reminderTemplate.content.cloneNode(true); + const row = fragment.querySelector(".reminder"); + row.querySelector(".reminderEnabled").checked = reminder.enabled ?? true; + row.querySelector(".reminderTitle").value = reminder.title || ""; + row.querySelector(".reminderDate").value = reminder.initial_date || ""; + row.querySelector(".reminderDescription").value = reminder.description || ""; + row.querySelector(".removeReminder").addEventListener("click", () => row.remove()); + elements.reminders.append(row); +} + +async function saveEntry() { + const selectedOption = elements.contactSelect.selectedOptions[0]; + const entry = { + writeToMonica: elements.writeToMonica.checked, + type: elements.typeSelect.value, + summary: elements.summaryInput.value.trim(), + rawText: state.analysis?.rawText || elements.inputText.value, + contact: selectedOption?.value + ? { id: Number(selectedOption.value), name: selectedOption.dataset.name || selectedOption.textContent } + : null, + note: { + enabled: elements.noteEnabled.checked, + body: elements.noteBody.value.trim(), + isFavorited: state.analysis?.note?.isFavorited || false, + }, + journal: { + enabled: elements.journalEnabled.checked, + title: elements.journalTitle.value.trim(), + post: elements.journalPost.value.trim(), + }, + reminders: collectReminders(), + }; + + elements.saveButton.disabled = true; + try { + const result = await api("/api/save", { + method: "POST", + body: JSON.stringify(entry), + }); + const monicaSummary = result.monicaResults?.length + ? ` Monica: ${result.monicaResults.map((item) => `${item.type} ${item.ok ? "ok" : "Fehler"}`).join(", ")}.` + : ""; + log(`Gespeichert.${monicaSummary}`); + } catch (error) { + log(error.message, "error"); + } finally { + elements.saveButton.disabled = false; + } +} + +function collectReminders() { + return [...elements.reminders.querySelectorAll(".reminder")].map((row) => ({ + enabled: row.querySelector(".reminderEnabled").checked, + title: row.querySelector(".reminderTitle").value.trim(), + initial_date: row.querySelector(".reminderDate").value, + description: row.querySelector(".reminderDescription").value.trim(), + frequency_type: "one_time", + frequency_number: 1, + })); +} + +function setupSpeechRecognition() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) { + elements.voiceButton.textContent = "Sprache nicht unterstützt"; + elements.voiceButton.disabled = true; + return; + } + + const recognition = new SpeechRecognition(); + recognition.lang = "de-DE"; + recognition.continuous = true; + recognition.interimResults = true; + + recognition.addEventListener("result", (event) => { + let finalText = ""; + let interimText = ""; + + for (let index = event.resultIndex; index < event.results.length; index += 1) { + const transcript = event.results[index][0].transcript; + if (event.results[index].isFinal) { + finalText += transcript; + } else { + interimText += transcript; + } + } + + if (finalText) { + elements.inputText.value = `${elements.inputText.value.trim()} ${finalText}`.trim(); + } + + if (interimText) { + log(`Höre: ${interimText}`); + } + }); + + recognition.addEventListener("end", () => { + state.listening = false; + elements.voiceButton.classList.remove("listening"); + elements.voiceButton.textContent = "Aufnehmen"; + }); + + recognition.addEventListener("error", (event) => { + log(`Spracherkennung: ${event.error}`, "error"); + }); + + state.recognition = recognition; +} + +function toggleSpeech() { + if (!state.recognition) return; + + if (state.listening) { + state.recognition.stop(); + return; + } + + state.listening = true; + elements.voiceButton.classList.add("listening"); + elements.voiceButton.textContent = "Stoppen"; + state.recognition.start(); +} + +async function api(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.detail || payload.error || response.statusText); + } + return payload; +} + +function log(message, level = "info") { + elements.messageLog.textContent = message || ""; + elements.messageLog.dataset.level = level; +} + +function debounce(fn, delay) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} diff --git a/public/icons/app-icon-source.png b/public/icons/app-icon-source.png new file mode 100644 index 0000000..9254b8b Binary files /dev/null and b/public/icons/app-icon-source.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..7fd4f39 --- /dev/null +++ b/public/index.html @@ -0,0 +1,150 @@ + + + + + + Misch & Friends + + + + + + +
+
+ +
+

Voice-first companion

+

Misch & Friends

+

Freundschaften festhalten, ohne Formularwahnsinn.

+
+
+ +
+
+ Voice + Eingabe +
+
+ Prüfe... + Monica +
+
+
+ +
+
+
+
+ + + +
+
+ +
+ +
+ + + +
+ + +
+
+ + + +
+
+ + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..86290a4 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,408 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +[hidden] { + display: none !important; +} + +:root { + color-scheme: dark; + --bg: #070914; + --surface: #111624; + --surface-2: #171d2d; + --surface-hover: #1c2435; + --border: rgba(226, 232, 240, 0.1); + --border-strong: rgba(226, 232, 240, 0.18); + --text: #eef2ff; + --muted: #9aa6bd; + --dim: #647085; + --ok: #34d399; + --warn: #fbbf24; + --down: #fb7185; + --cyan: #67e8f9; + --radius: 8px; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +html { + min-height: 100%; + font-size: 16px; + background: var(--bg); +} + +body { + min-height: 100vh; + color: var(--text); + background: + linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(8, 13, 27, 0.98)), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 120px); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +input, +select, +textarea { + border: 0; +} + +.shell { + width: min(1160px, calc(100% - 32px)); + margin-inline: auto; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: start; + padding: 32px 0 22px; +} + +.brand { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} + +.brand-mark { + width: 72px; + height: 72px; + flex: 0 0 auto; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--radius); + object-fit: cover; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.34); +} + +.brand h1 { + display: inline-block; + margin-top: 4px; + background: linear-gradient(135deg, #38bdf8 0%, #c084fc 100%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-size: clamp(2rem, 6vw, 4.4rem); + line-height: 0.95; + letter-spacing: 0; +} + +.eyebrow, +.section-kicker { + color: var(--cyan); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.subtitle { + max-width: 560px; + margin-top: 10px; + color: var(--muted); + font-size: clamp(0.95rem, 2vw, 1.05rem); +} + +.hero-meta { + display: grid; + grid-template-columns: repeat(2, minmax(92px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.metric { + min-width: 92px; + padding: 13px 14px; + background: rgba(17, 22, 36, 0.78); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.metric-value { + display: block; + color: #fff; + font-size: 0.96rem; + font-weight: 800; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.metric-label { + display: block; + margin-top: 8px; + color: var(--muted); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.control-bar, +.composer, +.review, +.log { + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(17, 22, 36, 0.74); + box-shadow: 0 18px 52px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(14px); +} + +.control-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding: 12px; +} + +.composer, +.review { + padding: 18px; +} + +.composer textarea { + margin-top: 10px; + resize: vertical; +} + +.review { + margin-top: 16px; +} + +.segments { + display: inline-flex; + gap: 4px; + padding: 4px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(7, 9, 20, 0.45); +} + +.segment, +button { + min-height: 38px; + padding: 0 14px; + color: var(--text); + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius); + cursor: pointer; +} + +.segment { + color: var(--muted); + font-size: 0.9rem; + font-weight: 700; +} + +.segment.is-active, +.segment.active, +.primary { + color: #062027; + background: var(--cyan); + border-color: transparent; + font-weight: 800; + box-shadow: 0 10px 28px rgba(103, 232, 249, 0.16); +} + +.icon-button, +.ghost, +.small { + color: var(--text); + background: rgba(17, 22, 36, 0.82); + border: 1px solid var(--border); +} + +button:hover { + background: rgba(103, 232, 249, 0.1); + border-color: rgba(103, 232, 249, 0.32); +} + +.voice.listening { + color: #062027; + background: var(--cyan); +} + +.actions, +.reviewHead, +.panelTitle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.actions { + margin-top: 14px; +} + +.input-label, +label { + display: grid; + gap: 8px; + color: var(--muted); + font-size: 0.86rem; +} + +textarea, +input, +select { + width: 100%; + min-height: 42px; + padding: 11px 12px; + color: var(--text); + background: rgba(7, 9, 20, 0.58); + border: 1px solid var(--border); + border-radius: var(--radius); + outline: none; +} + +textarea:focus, +input:focus, +select:focus { + border-color: rgba(103, 232, 249, 0.62); + box-shadow: 0 0 0 3px rgba(103, 232, 249, 0.1); +} + +textarea::placeholder, +input::placeholder { + color: var(--dim); +} + +.grid, +.cards { + display: grid; + gap: 12px; +} + +.grid { + grid-template-columns: minmax(0, 1.5fr) minmax(180px, 0.7fr); + margin: 18px 0 12px; +} + +.cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 12px 0; +} + +.panel { + padding: 14px; + background: rgba(7, 9, 20, 0.28); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.panel > * + * { + margin-top: 12px; +} + +.toggle { + display: inline-flex; + grid-template-columns: auto auto; + align-items: center; + gap: 8px; + color: var(--text); +} + +.toggle input { + width: 18px; + min-height: 18px; + height: 18px; + accent-color: var(--cyan); +} + +.small { + min-height: 32px; + padding: 0 10px; +} + +.danger { + color: #fecdd3; +} + +.reminder { + display: grid; + grid-template-columns: 90px minmax(0, 1fr) 170px auto; + gap: 10px; + align-items: start; + padding: 12px 0; + border-top: 1px solid var(--border); +} + +.reminder textarea { + grid-column: 2 / 4; +} + +.log { + margin-top: 16px; + min-height: 46px; + padding: 12px 14px; + color: var(--muted); +} + +.log:empty { + display: none; +} + +@media (max-width: 780px) { + .hero, + .control-bar, + .actions, + .reviewHead { + grid-template-columns: 1fr; + align-items: stretch; + } + + .hero, + .control-bar, + .actions, + .reviewHead { + flex-direction: column; + } + + .brand { + align-items: flex-start; + } + + .brand-mark { + width: 56px; + height: 56px; + } + + .hero-meta, + .grid, + .cards, + .reminder { + grid-template-columns: 1fr; + } + + .reminder textarea { + grid-column: auto; + } + + .segments { + width: 100%; + } + + .segment { + flex: 1; + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..76e4c8b --- /dev/null +++ b/server.js @@ -0,0 +1,359 @@ +import dotenv from "dotenv"; +import express from "express"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +dotenv.config(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const port = Number(process.env.PORT || 38110); +const dataDir = path.resolve(__dirname, process.env.DATA_DIR || "./data"); +const monicaBaseUrl = (process.env.MONICA_BASE_URL || "https://crm.mischlabs.de").replace(/\/$/, ""); +const monicaApiToken = process.env.MONICA_API_TOKEN || ""; + +app.use(express.json({ limit: "1mb" })); +app.use(express.static(path.join(__dirname, "public"))); + +app.get("/api/health", async (_req, res) => { + res.json({ + ok: true, + monicaConfigured: Boolean(monicaApiToken), + monicaBaseUrl, + }); +}); + +app.get("/api/contacts", async (req, res) => { + const query = String(req.query.query || "").trim(); + if (!monicaApiToken) { + res.json({ + mode: "demo", + data: demoContacts(query), + }); + return; + } + + try { + const suffix = query ? `?query=${encodeURIComponent(query)}` : ""; + const payload = await monicaFetch(`/api/contacts${suffix}`); + const contacts = Array.isArray(payload.data) ? payload.data : []; + res.json({ + mode: "monica", + data: contacts.map((contact) => ({ + id: contact.id, + name: contact.complete_name || [contact.first_name, contact.last_name].filter(Boolean).join(" "), + description: contact.description || "", + })), + }); + } catch (error) { + res.status(502).json({ error: "Monica contacts could not be loaded.", detail: error.message }); + } +}); + +app.post("/api/analyze", async (req, res) => { + const text = String(req.body.text || "").trim(); + const mode = String(req.body.mode || "auto"); + + if (!text) { + res.status(400).json({ error: "Text is required." }); + return; + } + + res.json(analyzeText(text, mode)); +}); + +app.post("/api/save", async (req, res) => { + const entry = req.body || {}; + const savedAt = new Date().toISOString(); + const localRecord = { + id: cryptoRandomId(), + savedAt, + entry, + monicaResults: [], + }; + + if (monicaApiToken && entry.writeToMonica) { + localRecord.monicaResults = await writeToMonica(entry); + } + + await appendLocalRecord(localRecord); + res.json(localRecord); +}); + +async function monicaFetch(apiPath, options = {}) { + const response = await fetch(`${monicaBaseUrl}${apiPath}`, { + ...options, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${monicaApiToken}`, + ...(options.headers || {}), + }, + }); + + const text = await response.text(); + const payload = text ? JSON.parse(text) : {}; + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`); + } + + return payload; +} + +async function writeToMonica(entry) { + const results = []; + + if (entry.journal?.enabled && entry.journal?.title && entry.journal?.post) { + results.push(await monicaWrite("journal", "/api/journal", { + title: entry.journal.title, + post: entry.journal.post, + })); + } + + const contactId = Number(entry.contact?.id || 0); + if (contactId && entry.note?.enabled && entry.note?.body) { + results.push(await monicaWrite("note", "/api/notes", { + contact_id: contactId, + body: entry.note.body, + is_favorited: Boolean(entry.note.isFavorited), + })); + } + + if (contactId && Array.isArray(entry.reminders)) { + for (const reminder of entry.reminders.filter((item) => item.enabled && item.initial_date && item.title)) { + results.push(await monicaWrite("reminder", "/api/reminders", { + contact_id: contactId, + initial_date: reminder.initial_date, + frequency_type: reminder.frequency_type || "one_time", + frequency_number: Number(reminder.frequency_number || 1), + title: reminder.title, + description: reminder.description || "", + delible: true, + })); + } + } + + return results; +} + +async function monicaWrite(type, apiPath, body) { + try { + const payload = await monicaFetch(apiPath, { + method: "POST", + body: JSON.stringify(body), + }); + return { type, ok: true, id: payload.data?.id || null }; + } catch (error) { + return { type, ok: false, error: error.message }; + } +} + +function analyzeText(text, mode = "auto") { + const normalized = text.replace(/\s+/g, " ").trim(); + const contacts = mode === "journal" ? [] : inferContacts(normalized); + const reminder = inferReminder(normalized); + const type = mode === "auto" ? inferType(normalized, contacts, reminder) : mode; + const summary = summarize(normalized); + const today = new Date(); + + return { + rawText: text, + type, + summary, + contacts, + contact: contacts[0] || null, + note: { + enabled: type !== "journal", + body: buildContactNote(normalized, reminder), + isFavorited: /wichtig|merken|nicht vergessen|unbedingt/i.test(normalized), + }, + journal: { + enabled: type === "journal" || type === "mixed", + title: inferJournalTitle(normalized, today), + post: normalized, + }, + reminders: reminder ? [reminder] : [], + confidence: contacts.length || reminder ? "medium" : "low", + }; +} + +function inferType(text, contacts, reminder) { + if (/tagebuch|journal|heute war|ich fühle|ich habe mich|gedanken/i.test(text) && (contacts.length || reminder)) { + return "mixed"; + } + if (/tagebuch|journal|heute war|ich fühle|ich habe mich|gedanken/i.test(text)) { + return "journal"; + } + if (contacts.length || reminder) { + return "contact"; + } + return "journal"; +} + +function inferContacts(text) { + const candidates = []; + const patterns = [ + /(?:mit|bei|von|über|wegen)\s+([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g, + /([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)\s+(?:getroffen|gesprochen|telefoniert|gesehen)/g, + /(?:treffe|spreche|telefoniere|schreibe)\s+(?:mit\s+)?([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g, + /@([A-Za-zÄÖÜäöüß]+(?:\s+[A-Za-zÄÖÜäöüß]+)?)/g, + ]; + + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const name = cleanupName(match[1]); + if (name && !candidates.some((item) => item.name.toLowerCase() === name.toLowerCase())) { + candidates.push({ name }); + } + } + } + + return candidates.slice(0, 3); +} + +function cleanupName(name) { + const cleaned = name + .replace(/\b(heute|morgen|gestern|freitag|samstag|sonntag|montag|dienstag|mittwoch|donnerstag)\b/gi, "") + .trim(); + if (/^(ich|er|sie|wir|frag|frage|denk)$/i.test(cleaned)) return ""; + return cleaned; +} + +function inferReminder(text) { + const date = inferReminderDate(text); + if (!date) return null; + + const titleMatch = text.match(/(?:erinnere mich|nachfragen|frag(?:e)?|denk daran|nicht vergessen)[,:]?\s*(.+)$/i); + const title = titleMatch ? cleanupReminderTitle(titleMatch[1]) : "Nachfassen"; + + return { + enabled: true, + title: title.slice(0, 160), + description: text, + initial_date: date, + frequency_type: "one_time", + frequency_number: 1, + }; +} + +function cleanupReminderTitle(title) { + return title + .split(/[.!?]/)[0] + .replace(/^(ihn|sie|es|mich|uns)\s+/i, "") + .replace(/\bam\s+(montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag)\b,?\s*/i, "") + .replace(/\bin\s+\d{1,3}\s+(tagen?|wochen?)\b,?\s*/i, "") + .replace(/\bwie\s+es\s+lief\b/i, "fragen, wie es lief") + .trim() + .replace(/^./, (char) => char.toUpperCase()) || "Nachfassen"; +} + +function inferReminderDate(text) { + const now = new Date(); + const lower = text.toLowerCase(); + + if (lower.includes("morgen")) return toDateString(addDays(now, 1)); + if (lower.includes("übermorgen") || lower.includes("uebermorgen")) return toDateString(addDays(now, 2)); + + const daysMatch = lower.match(/in\s+(\d{1,3})\s+tagen?/); + if (daysMatch) return toDateString(addDays(now, Number(daysMatch[1]))); + + const weeksMatch = lower.match(/in\s+(\d{1,2})\s+wochen?/); + if (weeksMatch) return toDateString(addDays(now, Number(weeksMatch[1]) * 7)); + + const weekdays = { + montag: 1, + dienstag: 2, + mittwoch: 3, + donnerstag: 4, + freitag: 5, + samstag: 6, + sonntag: 0, + }; + + for (const [name, day] of Object.entries(weekdays)) { + if (lower.includes(name)) return toDateString(nextWeekday(now, day)); + } + + const germanDate = lower.match(/\b(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?\b/); + if (germanDate) { + const year = germanDate[3] ? normalizeYear(Number(germanDate[3])) : now.getFullYear(); + return toDateString(new Date(year, Number(germanDate[2]) - 1, Number(germanDate[1]))); + } + + return null; +} + +function buildContactNote(text, reminder) { + if (!reminder) return text; + return `${text}\n\nErkannte Erinnerung: ${reminder.title} am ${reminder.initial_date}`; +} + +function inferJournalTitle(text, date) { + const firstSentence = text.split(/[.!?]/)[0]?.trim(); + if (firstSentence && firstSentence.length <= 80) return firstSentence; + return `Eintrag vom ${date.toLocaleDateString("de-DE")}`; +} + +function summarize(text) { + if (text.length <= 180) return text; + return `${text.slice(0, 177).trim()}...`; +} + +function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function nextWeekday(date, targetDay) { + const result = new Date(date); + const delta = (targetDay + 7 - result.getDay()) % 7 || 7; + result.setDate(result.getDate() + delta); + return result; +} + +function toDateString(date) { + return date.toISOString().slice(0, 10); +} + +function normalizeYear(year) { + if (year < 100) return 2000 + year; + return year; +} + +function demoContacts(query) { + const contacts = [ + { id: 1, name: "Aaron Lingel", description: "Demo-Kontakt" }, + { id: 2, name: "Tom", description: "Demo-Kontakt" }, + { id: 3, name: "MrDiderot", description: "Demo-Kontakt" }, + ]; + if (!query) return contacts; + return contacts.filter((contact) => contact.name.toLowerCase().includes(query.toLowerCase())); +} + +async function appendLocalRecord(record) { + await fs.mkdir(dataDir, { recursive: true }); + const file = path.join(dataDir, "entries.json"); + let entries = []; + + try { + entries = JSON.parse(await fs.readFile(file, "utf8")); + } catch { + entries = []; + } + + entries.unshift(record); + await fs.writeFile(file, JSON.stringify(entries.slice(0, 500), null, 2)); +} + +function cryptoRandomId() { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +app.listen(port, () => { + console.log(`Friends listening on http://localhost:${port}`); +});