Compare commits
8 Commits
88d47dc4e0
...
7b9a1eeb70
Author | SHA1 | Date | |
---|---|---|---|
7b9a1eeb70 | |||
74d5037f09 | |||
b7e8d1c921 | |||
ddb3c682f5 | |||
7d55a1369d | |||
eaf4d6b085 | |||
3608750616 | |||
53e2b15351 |
4
.gitignore
vendored
@@ -136,3 +136,7 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# server files
|
||||||
|
server/database.sqlite
|
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: openwall-server
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- server-data:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_PATH=/app/data/database.sqlite
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- openwall-network
|
||||||
|
|
||||||
|
mobile-calendar:
|
||||||
|
build:
|
||||||
|
context: ./mobile-calendar
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: openwall-mobile-calendar
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- openwall-network
|
||||||
|
|
||||||
|
mobile-shopping:
|
||||||
|
build:
|
||||||
|
context: ./mobile-shopping
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: openwall-mobile-shopping
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- openwall-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
server-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
openwall-network:
|
||||||
|
driver: bridge
|
17
mobile-calendar/.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
coverage
|
24
mobile-calendar/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
35
mobile-calendar/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Use Node.js official image for building
|
||||||
|
FROM node:18-alpine as builder
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install pnpm globally
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Use nginx for serving the built application
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built application from builder stage
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
29
mobile-calendar/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
26
mobile-calendar/index.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>OpenWall Calendar</title>
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="description" content="Mobile calendar app for OpenWall Smart Home Dashboard">
|
||||||
|
<meta name="theme-color" content="#3b82f6">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="OpenWall Calendar">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
|
<!-- Additional PWA Meta Tags -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="application-name" content="OpenWall Calendar">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
31
mobile-calendar/nginx.conf
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Handle client-side routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to the backend server
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://server:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
30
mobile-calendar/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "mobile-calendar",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.30.1",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"eslint": "^9.30.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"sass": "^1.89.2",
|
||||||
|
"vite": "^7.0.4",
|
||||||
|
"vite-plugin-pwa": "^1.0.1"
|
||||||
|
}
|
||||||
|
}
|
4789
mobile-calendar/pnpm-lock.yaml
generated
Normal file
BIN
mobile-calendar/public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
167
mobile-calendar/public/calendar-icon.svg
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<defs>
|
||||||
|
<!-- Background gradient -->
|
||||||
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||||
|
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Calendar body gradient -->
|
||||||
|
<linearGradient id="calendarGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#f1f5f9;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Header gradient -->
|
||||||
|
<linearGradient id="headerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#2563eb;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Today highlight gradient -->
|
||||||
|
<radialGradient id="todayGradient" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" style="stop-color:#60a5fa;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Drop shadow -->
|
||||||
|
<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="4" dy="8" stdDeviation="6" flood-color="#1e293b" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<!-- Inner shadow -->
|
||||||
|
<filter id="innerShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feOffset dx="0" dy="2"/>
|
||||||
|
<feGaussianBlur stdDeviation="4" result="offset-blur"/>
|
||||||
|
<feFlood flood-color="#334155" flood-opacity="0.15"/>
|
||||||
|
<feComposite in2="offset-blur" operator="in"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
<feMergeNode/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle cx="256" cy="256" r="240" fill="url(#bgGradient)" filter="url(#dropShadow)"/>
|
||||||
|
|
||||||
|
<!-- Calendar container -->
|
||||||
|
<rect x="96" y="80" width="320" height="352" rx="24" ry="24"
|
||||||
|
fill="url(#calendarGradient)"
|
||||||
|
stroke="#e2e8f0"
|
||||||
|
stroke-width="4"
|
||||||
|
filter="url(#innerShadow)"/>
|
||||||
|
|
||||||
|
<!-- Calendar header -->
|
||||||
|
<rect x="96" y="80" width="320" height="80" rx="24" ry="24"
|
||||||
|
fill="url(#headerGradient)"/>
|
||||||
|
<rect x="96" y="136" width="320" height="24"
|
||||||
|
fill="url(#headerGradient)"/>
|
||||||
|
|
||||||
|
<!-- Binding rings -->
|
||||||
|
<circle cx="160" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
|
||||||
|
<circle cx="256" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
|
||||||
|
<circle cx="352" cy="64" r="12" fill="#64748b" stroke="#475569" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Binding ring holes -->
|
||||||
|
<circle cx="160" cy="64" r="6" fill="#334155"/>
|
||||||
|
<circle cx="256" cy="64" r="6" fill="#334155"/>
|
||||||
|
<circle cx="352" cy="64" r="6" fill="#334155"/>
|
||||||
|
|
||||||
|
<!-- Month/Year text area -->
|
||||||
|
<rect x="120" y="100" width="272" height="40" rx="8" ry="8" fill="#ffffff" opacity="0.9"/>
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
<g stroke="#cbd5e1" stroke-width="1" opacity="0.6">
|
||||||
|
<!-- Vertical lines -->
|
||||||
|
<line x1="141" y1="160" x2="141" y2="416"/>
|
||||||
|
<line x1="186" y1="160" x2="186" y2="416"/>
|
||||||
|
<line x1="231" y1="160" x2="231" y2="416"/>
|
||||||
|
<line x1="276" y1="160" x2="276" y2="416"/>
|
||||||
|
<line x1="321" y1="160" x2="321" y2="416"/>
|
||||||
|
<line x1="366" y1="160" x2="366" y2="416"/>
|
||||||
|
|
||||||
|
<!-- Horizontal lines -->
|
||||||
|
<line x1="96" y1="196" x2="416" y2="196"/>
|
||||||
|
<line x1="96" y1="232" x2="416" y2="232"/>
|
||||||
|
<line x1="96" y1="268" x2="416" y2="268"/>
|
||||||
|
<line x1="96" y1="304" x2="416" y2="304"/>
|
||||||
|
<line x1="96" y1="340" x2="416" y2="340"/>
|
||||||
|
<line x1="96" y1="376" x2="416" y2="376"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Day of week labels area -->
|
||||||
|
<rect x="96" y="160" width="320" height="36" fill="#f8fafc" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Sample dates -->
|
||||||
|
<g fill="#64748b" font-family="Arial, sans-serif" font-size="16" font-weight="500" text-anchor="middle">
|
||||||
|
<!-- Week days (abbreviated) -->
|
||||||
|
<text x="118" y="182" fill="#475569" font-size="12">M</text>
|
||||||
|
<text x="163" y="182" fill="#475569" font-size="12">T</text>
|
||||||
|
<text x="208" y="182" fill="#475569" font-size="12">W</text>
|
||||||
|
<text x="253" y="182" fill="#475569" font-size="12">T</text>
|
||||||
|
<text x="298" y="182" fill="#475569" font-size="12">F</text>
|
||||||
|
<text x="343" y="182" fill="#475569" font-size="12">S</text>
|
||||||
|
<text x="388" y="182" fill="#475569" font-size="12">S</text>
|
||||||
|
|
||||||
|
<!-- Sample date numbers -->
|
||||||
|
<text x="118" y="218">1</text>
|
||||||
|
<text x="163" y="218">2</text>
|
||||||
|
<text x="208" y="218">3</text>
|
||||||
|
<text x="253" y="218">4</text>
|
||||||
|
<text x="298" y="218">5</text>
|
||||||
|
<text x="343" y="218">6</text>
|
||||||
|
<text x="388" y="218">7</text>
|
||||||
|
|
||||||
|
<text x="118" y="254">8</text>
|
||||||
|
<text x="163" y="254">9</text>
|
||||||
|
<text x="208" y="254">10</text>
|
||||||
|
<text x="253" y="254">11</text>
|
||||||
|
<text x="298" y="254">12</text>
|
||||||
|
<text x="343" y="254">13</text>
|
||||||
|
<text x="388" y="254">14</text>
|
||||||
|
|
||||||
|
<text x="118" y="290">15</text>
|
||||||
|
<text x="163" y="290">16</text>
|
||||||
|
<text x="208" y="290">17</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Today highlight (day 18) -->
|
||||||
|
<circle cx="253" cy="285" r="18" fill="url(#todayGradient)" opacity="0.9"/>
|
||||||
|
<text x="253" y="291" fill="white" font-family="Arial, sans-serif" font-size="16" font-weight="600" text-anchor="middle">18</text>
|
||||||
|
|
||||||
|
<!-- Additional date numbers -->
|
||||||
|
<g fill="#64748b" font-family="Arial, sans-serif" font-size="16" font-weight="500" text-anchor="middle">
|
||||||
|
<text x="298" y="290">19</text>
|
||||||
|
<text x="343" y="290">20</text>
|
||||||
|
<text x="388" y="290">21</text>
|
||||||
|
|
||||||
|
<text x="118" y="326">22</text>
|
||||||
|
<text x="163" y="326">23</text>
|
||||||
|
<text x="208" y="326">24</text>
|
||||||
|
<text x="253" y="326">25</text>
|
||||||
|
<text x="298" y="326">26</text>
|
||||||
|
<text x="343" y="326">27</text>
|
||||||
|
<text x="388" y="326">28</text>
|
||||||
|
|
||||||
|
<text x="118" y="362">29</text>
|
||||||
|
<text x="163" y="362">30</text>
|
||||||
|
<text x="208" y="362">31</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Event indicators -->
|
||||||
|
<circle cx="163" cy="244" r="3" fill="#10b981"/>
|
||||||
|
<circle cx="298" cy="244" r="3" fill="#f59e0b"/>
|
||||||
|
<circle cx="343" cy="280" r="3" fill="#ef4444"/>
|
||||||
|
<circle cx="118" cy="316" r="3" fill="#8b5cf6"/>
|
||||||
|
|
||||||
|
<!-- Small OpenWall branding element -->
|
||||||
|
<g transform="translate(360, 380)" opacity="0.4">
|
||||||
|
<circle cx="16" cy="16" r="12" fill="#3b82f6"/>
|
||||||
|
<rect x="10" y="10" width="12" height="12" rx="2" fill="white" opacity="0.8"/>
|
||||||
|
<rect x="12" y="12" width="8" height="2" fill="#3b82f6"/>
|
||||||
|
<circle cx="14" cy="16" r="1" fill="#3b82f6"/>
|
||||||
|
<circle cx="18" cy="16" r="1" fill="#3b82f6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 6.6 KiB |
BIN
mobile-calendar/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 947 B |
BIN
mobile-calendar/public/favicon.ico
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
mobile-calendar/public/favicon.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
mobile-calendar/public/pwa-192x192.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
mobile-calendar/public/pwa-512x512.png
Normal file
After Width: | Height: | Size: 21 KiB |
613
mobile-calendar/src/App.jsx
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
FaPlus,
|
||||||
|
FaTrash,
|
||||||
|
FaEdit,
|
||||||
|
FaCalendarAlt,
|
||||||
|
FaUsers,
|
||||||
|
FaUser,
|
||||||
|
FaChevronLeft,
|
||||||
|
FaChevronRight,
|
||||||
|
FaSave,
|
||||||
|
FaTimes,
|
||||||
|
FaBirthdayCake,
|
||||||
|
FaGift
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import calendarService from './services/CalendarService';
|
||||||
|
import './App.sass';
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [viewType, setViewType] = useState('family'); // 'family' or 'individual'
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState(null);
|
||||||
|
const [newEvent, setNewEvent] = useState({
|
||||||
|
user: '',
|
||||||
|
date: '',
|
||||||
|
text: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers();
|
||||||
|
loadEvents();
|
||||||
|
}, [currentDate, viewType, selectedUser]);
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await calendarService.getUsers();
|
||||||
|
setUsers(userData);
|
||||||
|
if (userData.length > 0 && !selectedUser) {
|
||||||
|
setSelectedUser(userData[0].name);
|
||||||
|
setNewEvent(prev => ({ ...prev, user: userData[0].name }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Benutzer');
|
||||||
|
console.error('Error loading users:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
|
||||||
|
const eventData = await calendarService.getEventsForMonth(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
selectedUser,
|
||||||
|
viewType
|
||||||
|
);
|
||||||
|
|
||||||
|
setEvents(eventData);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Termine');
|
||||||
|
console.error('Error loading events:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddEvent = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newEvent.text.trim() || !newEvent.date || !newEvent.user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdEvent = await calendarService.createEvent(newEvent);
|
||||||
|
setEvents([...events, createdEvent]);
|
||||||
|
setNewEvent({
|
||||||
|
user: selectedUser || users[0]?.name || '',
|
||||||
|
date: '',
|
||||||
|
text: ''
|
||||||
|
});
|
||||||
|
setShowAddForm(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Erstellen des Termins');
|
||||||
|
console.error('Error creating event:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateEvent = async (eventId, updatedData) => {
|
||||||
|
try {
|
||||||
|
const updatedEvent = await calendarService.updateEvent(eventId, updatedData);
|
||||||
|
setEvents(events.map(event =>
|
||||||
|
event.id === eventId ? updatedEvent : event
|
||||||
|
));
|
||||||
|
setEditingEvent(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating event:', err);
|
||||||
|
|
||||||
|
// Show more specific error messages based on the error
|
||||||
|
if (err.message && err.message.includes('belong to you')) {
|
||||||
|
setError('Dieser Termin gehört einem anderen Benutzer und kann nicht bearbeitet werden');
|
||||||
|
} else if (err.message && err.message.includes('not found')) {
|
||||||
|
setError('Termin nicht gefunden');
|
||||||
|
} else if (err.message && err.message.includes('uid already exists')) {
|
||||||
|
setError('Fehler beim Aktualisieren: Termin-ID bereits vorhanden');
|
||||||
|
} else {
|
||||||
|
setError('Fehler beim Aktualisieren des Termins');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEvent = async (eventId, user) => {
|
||||||
|
try {
|
||||||
|
// Additional protection: don't allow deleting birthday events
|
||||||
|
if (eventId && eventId.includes('birthday-')) {
|
||||||
|
setError('Geburtstage können nicht über die Kalender-App gelöscht werden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await calendarService.deleteEvent(eventId, user);
|
||||||
|
setEvents(events.filter(event => event.id !== eventId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting event:', err);
|
||||||
|
|
||||||
|
// Show more specific error messages based on the error
|
||||||
|
if (err.message && err.message.includes('belong to you')) {
|
||||||
|
setError('Dieser Termin gehört einem anderen Benutzer und kann nicht gelöscht werden');
|
||||||
|
} else if (err.message && err.message.includes('not found')) {
|
||||||
|
setError('Termin nicht gefunden oder bereits gelöscht');
|
||||||
|
} else if (err.message && err.message.includes('Birthday events')) {
|
||||||
|
setError('Geburtstage können nicht gelöscht werden');
|
||||||
|
} else {
|
||||||
|
setError('Fehler beim Löschen des Termins');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateMonth = (direction) => {
|
||||||
|
const newDate = new Date(currentDate);
|
||||||
|
newDate.setMonth(newDate.getMonth() + direction);
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
// Parse the date string ensuring we don't have timezone issues
|
||||||
|
const dateParts = dateString.split('-');
|
||||||
|
const date = new Date(parseInt(dateParts[0]), parseInt(dateParts[1]) - 1, parseInt(dateParts[2]));
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0); // Reset time for comparison
|
||||||
|
|
||||||
|
// Create a date object for the event date for comparison
|
||||||
|
const eventDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
|
||||||
|
return {
|
||||||
|
dayNumber: date.getDate(),
|
||||||
|
dayName: date.toLocaleDateString('de-DE', { weekday: 'long' }),
|
||||||
|
monthYear: date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }),
|
||||||
|
isToday: eventDate.getTime() === today.getTime(),
|
||||||
|
isThisMonth: date.getMonth() === currentDate.getMonth() && date.getFullYear() === currentDate.getFullYear()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentMonthYear = () => {
|
||||||
|
return currentDate.toLocaleDateString('de-DE', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupEventsByDate = (events) => {
|
||||||
|
// First, ensure events are properly sorted by date
|
||||||
|
const sortedEvents = [...events].sort((a, b) => {
|
||||||
|
// Compare dates as strings to avoid timezone issues
|
||||||
|
if (a.date !== b.date) {
|
||||||
|
return a.date.localeCompare(b.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dates are the same, sort by type (birthdays first)
|
||||||
|
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
|
||||||
|
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
|
||||||
|
|
||||||
|
// Then sort by event text for consistent ordering
|
||||||
|
return (a.text || '').localeCompare(b.text || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
sortedEvents.forEach(event => {
|
||||||
|
const date = event.date;
|
||||||
|
if (!grouped[date]) {
|
||||||
|
grouped[date] = [];
|
||||||
|
}
|
||||||
|
grouped[date].push(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort dates chronologically using string comparison for YYYY-MM-DD format
|
||||||
|
const sortedDates = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
sortedDates.forEach(date => {
|
||||||
|
// Events within each date are already sorted from the initial sort
|
||||||
|
result[date] = grouped[date];
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedEvents = groupEventsByDate(events);
|
||||||
|
|
||||||
|
const getUserColor = (userName, eventType) => {
|
||||||
|
// Different color scheme for birthdays
|
||||||
|
if (eventType === 'birthday') {
|
||||||
|
return '#ff6b9d'; // Pink for birthdays
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#10b981', // green
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#ef4444', // red
|
||||||
|
'#8b5cf6', // purple
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#84cc16' // lime
|
||||||
|
];
|
||||||
|
|
||||||
|
const userIndex = users.findIndex(user => user.name === userName);
|
||||||
|
return colors[userIndex % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="loading">
|
||||||
|
<FaCalendarAlt className="loading-icon" />
|
||||||
|
<p>Kalender wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app calendar-app">
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-content">
|
||||||
|
<h1>
|
||||||
|
<FaCalendarAlt />
|
||||||
|
Kalender
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="month-navigation">
|
||||||
|
<button
|
||||||
|
className="btn btn-icon"
|
||||||
|
onClick={() => navigateMonth(-1)}
|
||||||
|
>
|
||||||
|
<FaChevronLeft />
|
||||||
|
</button>
|
||||||
|
<h2 className="current-month">{getCurrentMonthYear()}</h2>
|
||||||
|
<button
|
||||||
|
className="btn btn-icon"
|
||||||
|
onClick={() => navigateMonth(1)}
|
||||||
|
>
|
||||||
|
<FaChevronRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="view-controls">
|
||||||
|
<div className="view-type-toggle">
|
||||||
|
<button
|
||||||
|
className={`btn ${viewType === 'family' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setViewType('family')}
|
||||||
|
>
|
||||||
|
<FaUsers />
|
||||||
|
Familie
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${viewType === 'individual' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setViewType('individual')}
|
||||||
|
>
|
||||||
|
<FaUser />
|
||||||
|
Einzeln
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewType === 'individual' && (
|
||||||
|
<select
|
||||||
|
className="user-select"
|
||||||
|
value={selectedUser || ''}
|
||||||
|
onChange={(e) => setSelectedUser(e.target.value)}
|
||||||
|
>
|
||||||
|
{users.map(user => (
|
||||||
|
<option key={user.id} value={user.name}>
|
||||||
|
{user.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="add-form-overlay">
|
||||||
|
<form className="add-form" onSubmit={handleAddEvent}>
|
||||||
|
<h3>Neuer Termin</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Benutzer</label>
|
||||||
|
<select
|
||||||
|
value={newEvent.user}
|
||||||
|
onChange={(e) => setNewEvent({ ...newEvent, user: e.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Benutzer auswählen</option>
|
||||||
|
{users.map(user => (
|
||||||
|
<option key={user.id} value={user.name}>
|
||||||
|
{user.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Datum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newEvent.date}
|
||||||
|
onChange={(e) => setNewEvent({ ...newEvent, date: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Termin beschreiben..."
|
||||||
|
value={newEvent.text}
|
||||||
|
onChange={(e) => setNewEvent({ ...newEvent, text: e.target.value })}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setShowAddForm(false)}>
|
||||||
|
<FaTimes />
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<FaSave />
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="calendar-content">
|
||||||
|
{Object.keys(groupedEvents).length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<FaCalendarAlt />
|
||||||
|
<p>Keine Termine in diesem Monat</p>
|
||||||
|
<small>
|
||||||
|
{viewType === 'family'
|
||||||
|
? 'Füge einen neuen Termin hinzu für alle Familienmitglieder'
|
||||||
|
: `Keine Termine für ${selectedUser} gefunden`
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="events-list">
|
||||||
|
{Object.entries(groupedEvents).map(([date, dayEvents]) => {
|
||||||
|
const dateInfo = formatDate(date);
|
||||||
|
return (
|
||||||
|
<div key={date} className={`day-section ${dateInfo.isToday ? 'today' : ''} ${!dateInfo.isThisMonth ? 'other-month' : ''}`}>
|
||||||
|
<div className="day-header">
|
||||||
|
<div className="day-info">
|
||||||
|
<div className="day-number">{dateInfo.dayNumber}</div>
|
||||||
|
<div className="day-details">
|
||||||
|
<div className="day-name">{dateInfo.dayName}</div>
|
||||||
|
{!dateInfo.isThisMonth && (
|
||||||
|
<div className="month-indicator">{dateInfo.monthYear}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="event-count">
|
||||||
|
<span className="count-number">{dayEvents.length}</span>
|
||||||
|
<span className="count-label">Termin{dayEvents.length !== 1 ? 'e' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="day-events">
|
||||||
|
{dayEvents.map(event => (
|
||||||
|
<CalendarEvent
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
onUpdate={handleUpdateEvent}
|
||||||
|
onDelete={handleDeleteEvent}
|
||||||
|
editingEvent={editingEvent}
|
||||||
|
setEditingEvent={setEditingEvent}
|
||||||
|
userColor={getUserColor(event.user, event.type)}
|
||||||
|
showUser={viewType === 'family'}
|
||||||
|
users={users}
|
||||||
|
viewType={viewType}
|
||||||
|
selectedUser={selectedUser}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="fab-container">
|
||||||
|
<button
|
||||||
|
className="fab"
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calendar Event Component
|
||||||
|
const CalendarEvent = ({
|
||||||
|
event,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
editingEvent,
|
||||||
|
setEditingEvent,
|
||||||
|
userColor,
|
||||||
|
showUser,
|
||||||
|
users,
|
||||||
|
viewType,
|
||||||
|
selectedUser
|
||||||
|
}) => {
|
||||||
|
const [editData, setEditData] = useState({
|
||||||
|
user: event.user,
|
||||||
|
date: event.date,
|
||||||
|
text: event.text
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
// Don't allow editing of birthday events (they come from external calendar)
|
||||||
|
if (event.type === 'birthday') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In individual view, only allow editing events that belong to the selected user
|
||||||
|
if (viewType === 'individual' && event.user !== selectedUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingEvent(event.id);
|
||||||
|
setEditData({
|
||||||
|
user: event.user,
|
||||||
|
date: event.date,
|
||||||
|
text: event.text
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onUpdate(event.id, editData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditingEvent(null);
|
||||||
|
setEditData({
|
||||||
|
user: event.user,
|
||||||
|
date: event.date,
|
||||||
|
text: event.text
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
// Don't allow deleting of birthday events (they come from external calendar)
|
||||||
|
if (event.type === 'birthday') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In individual view, only allow deleting events that belong to the selected user
|
||||||
|
if (viewType === 'individual' && event.user !== selectedUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always pass the event's original user for deletion (important for family view)
|
||||||
|
onDelete(event.id, event.user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEditing = editingEvent === event.id;
|
||||||
|
const isBirthday = event.type === 'birthday';
|
||||||
|
|
||||||
|
// Determine if this event can be edited/deleted
|
||||||
|
const canEdit = !isBirthday && (viewType === 'family' || event.user === selectedUser);
|
||||||
|
const canDelete = !isBirthday && (viewType === 'family' || event.user === selectedUser);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`calendar-event ${isBirthday ? 'birthday-event' : ''}`} style={{ borderLeftColor: userColor }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="edit-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<select
|
||||||
|
value={editData.user}
|
||||||
|
onChange={(e) => setEditData({ ...editData, user: e.target.value })}
|
||||||
|
>
|
||||||
|
{users.map(user => (
|
||||||
|
<option key={user.id} value={user.name}>
|
||||||
|
{user.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editData.date}
|
||||||
|
onChange={(e) => setEditData({ ...editData, date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.text}
|
||||||
|
onChange={(e) => setEditData({ ...editData, text: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSave();
|
||||||
|
if (e.key === 'Escape') handleCancel();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="edit-actions">
|
||||||
|
<button className="btn btn-primary btn-small" onClick={handleSave}>
|
||||||
|
<FaSave />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-small" onClick={handleCancel}>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="event-content">
|
||||||
|
<div className="event-text">
|
||||||
|
{isBirthday && (
|
||||||
|
<span className="birthday-icon">
|
||||||
|
<FaBirthdayCake />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isBirthday && event.contactName ? event.contactName : event.text}
|
||||||
|
{isBirthday && (
|
||||||
|
<span className="birthday-label">Geburtstag</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showUser && (
|
||||||
|
<div className="event-user" style={{ color: userColor }}>
|
||||||
|
{event.user}
|
||||||
|
{isBirthday && <span className="event-source"> (Kontakte)</span>}
|
||||||
|
{!canEdit && !isBirthday && <span className="read-only-indicator"> (nur lesen)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="event-actions">
|
||||||
|
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
|
||||||
|
<FaEdit />
|
||||||
|
</button>
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-small"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isBirthday && (
|
||||||
|
<div className="event-actions">
|
||||||
|
<div className="birthday-indicator">
|
||||||
|
<FaGift className="gift-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!canEdit && !isBirthday && (
|
||||||
|
<div className="event-actions">
|
||||||
|
<div className="read-only-indicator">
|
||||||
|
<span className="read-only-text">👁️</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
1000
mobile-calendar/src/App.sass
Normal file
556
mobile-calendar/src/App.scss
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
// Mobile Shopping App Styles
|
||||||
|
// Clean, simple design without glassmorphism
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #007AFF;
|
||||||
|
--secondary-color: #5856D6;
|
||||||
|
--success-color: #34C759;
|
||||||
|
--danger-color: #FF3B30;
|
||||||
|
--warning-color: #FF9500;
|
||||||
|
--background-color: #f8f9fa;
|
||||||
|
--surface-color: #ffffff;
|
||||||
|
--border-color: #e1e5e9;
|
||||||
|
--text-primary: #1d1d1f;
|
||||||
|
--text-secondary: #6e6e73;
|
||||||
|
--text-muted: #8e8e93;
|
||||||
|
--shadow-light: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-medium: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
--border-radius: 12px;
|
||||||
|
--border-radius-small: 8px;
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 16px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
.header {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Content
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 100px; // Space for FAB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
.error-message {
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading State
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60vh;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopping Content
|
||||||
|
.shopping-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-section {
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
padding: 0 var(--spacing-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty State
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shopping Item
|
||||||
|
.shopping-item {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checked {
|
||||||
|
opacity: 0.7;
|
||||||
|
background: #f8f9fa;
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-btn {
|
||||||
|
background: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
background: var(--surface-color);
|
||||||
|
color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--success-color);
|
||||||
|
background: rgba(52, 199, 89, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.item-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-amount {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: var(--spacing-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.deletion-timer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
color: var(--warning-color);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--surface-color);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #0056b3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-secondary {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-danger {
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-small {
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Form Overlay
|
||||||
|
.add-form-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--surface-color);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating Action Button
|
||||||
|
.fab-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--spacing-lg);
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive Design
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.main {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
|
||||||
|
.item-content .item-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
|
||||||
|
.item-amount {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form-overlay {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-container {
|
||||||
|
bottom: var(--spacing-md);
|
||||||
|
right: var(--spacing-md);
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
mobile-calendar/src/main.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
// Register service worker for PWA
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then((registration) => {
|
||||||
|
console.log('SW registered: ', registration);
|
||||||
|
})
|
||||||
|
.catch((registrationError) => {
|
||||||
|
console.log('SW registration failed: ', registrationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
54
mobile-calendar/src/services/CalendarService.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import requestUtil from '../utils/RequestUtil';
|
||||||
|
|
||||||
|
class CalendarService {
|
||||||
|
constructor() {
|
||||||
|
this.endpoint = '/api/calendar';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all calendar users
|
||||||
|
async getUsers() {
|
||||||
|
return requestUtil.get(`${this.endpoint}/users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get calendar events for a specific month
|
||||||
|
async getEventsForMonth(year, month, user = null, type = 'family') {
|
||||||
|
let url = `${this.endpoint}/events/${year}/${month}?type=${type}`;
|
||||||
|
if (user && type === 'individual') {
|
||||||
|
url += `&user=${encodeURIComponent(user)}`;
|
||||||
|
}
|
||||||
|
return requestUtil.get(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new calendar event
|
||||||
|
async createEvent(event) {
|
||||||
|
return requestUtil.post(`${this.endpoint}/events`, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a calendar event
|
||||||
|
async updateEvent(eventId, event) {
|
||||||
|
return requestUtil.put(`${this.endpoint}/events/${eventId}`, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a calendar event
|
||||||
|
async deleteEvent(eventId, user) {
|
||||||
|
return requestUtil.delete(`${this.endpoint}/events/${eventId}`, { user });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get contact birthdays for a specific month
|
||||||
|
async getContactBirthdaysForMonth(year, month, user) {
|
||||||
|
let url = `${this.endpoint}/birthdays/${year}/${month}`;
|
||||||
|
if (user) {
|
||||||
|
url += `?user=${encodeURIComponent(user)}`;
|
||||||
|
}
|
||||||
|
return requestUtil.get(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
async healthCheck() {
|
||||||
|
return requestUtil.get('/api/health');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export a singleton instance
|
||||||
|
const calendarService = new CalendarService();
|
||||||
|
export default calendarService;
|
51
mobile-calendar/src/services/ShoppingService.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import requestUtil from '../utils/RequestUtil';
|
||||||
|
|
||||||
|
class ShoppingService {
|
||||||
|
constructor() {
|
||||||
|
this.endpoint = '/api/shopping';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all shopping items
|
||||||
|
async getItems() {
|
||||||
|
return requestUtil.get(this.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a specific shopping item
|
||||||
|
async getItem(id) {
|
||||||
|
return requestUtil.get(`${this.endpoint}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new shopping item
|
||||||
|
async createItem(item) {
|
||||||
|
return requestUtil.post(this.endpoint, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a shopping item
|
||||||
|
async updateItem(id, item) {
|
||||||
|
return requestUtil.put(`${this.endpoint}/${id}`, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle checked status of an item
|
||||||
|
async toggleItem(id) {
|
||||||
|
return requestUtil.patch(`${this.endpoint}/${id}/toggle`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a shopping item
|
||||||
|
async deleteItem(id) {
|
||||||
|
return requestUtil.delete(`${this.endpoint}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all checked items
|
||||||
|
async deleteCheckedItems() {
|
||||||
|
return requestUtil.delete(`${this.endpoint}/checked/all`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
async healthCheck() {
|
||||||
|
return requestUtil.get('/api/health');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export a singleton instance
|
||||||
|
const shoppingService = new ShoppingService();
|
||||||
|
export default shoppingService;
|
91
mobile-calendar/src/utils/RequestUtil.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
class RequestUtil {
|
||||||
|
constructor() {
|
||||||
|
this.baseURL = this.getBaseURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseURL() {
|
||||||
|
// In production, use the static endpoint, otherwise use proxy
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return 'https://static.endpoint.com';
|
||||||
|
}
|
||||||
|
return ''; // Uses proxy in development
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(endpoint, options = {}) {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...defaultOptions,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||||
|
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET request
|
||||||
|
async get(endpoint) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST request
|
||||||
|
async post(endpoint, data = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT request
|
||||||
|
async put(endpoint, data = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH request
|
||||||
|
async patch(endpoint, data = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE request
|
||||||
|
async delete(endpoint, data = null) {
|
||||||
|
const options = {
|
||||||
|
method: 'DELETE'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
options.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.request(endpoint, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export a singleton instance
|
||||||
|
const requestUtil = new RequestUtil();
|
||||||
|
export default requestUtil;
|
53
mobile-calendar/vite.config.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
|
||||||
|
},
|
||||||
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'calendar-icon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'OpenWall Calendar',
|
||||||
|
short_name: 'Calendar',
|
||||||
|
description: 'Mobile calendar app for OpenWall Smart Home Dashboard',
|
||||||
|
theme_color: '#3b82f6',
|
||||||
|
background_color: '#f8f9fa',
|
||||||
|
display: 'standalone',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
17
mobile-shopping/.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
coverage
|
35
mobile-shopping/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Use Node.js official image for building
|
||||||
|
FROM node:18-alpine as builder
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install pnpm globally
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Use nginx for serving the built application
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built application from builder stage
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
31
mobile-shopping/nginx.conf
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Handle client-side routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to the backend server
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://server:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
12
server/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.nyc_output
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
30
server/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Use Node.js official image
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install pnpm globally
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create directory for SQLite database
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV DATABASE_PATH=/app/data/database.sqlite
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["pnpm", "start"]
|
@@ -4,6 +4,7 @@ require('dotenv').config();
|
|||||||
|
|
||||||
const { sequelize, cleanupCheckedItems } = require('./models');
|
const { sequelize, cleanupCheckedItems } = require('./models');
|
||||||
const shoppingRoutes = require('./routes/shopping');
|
const shoppingRoutes = require('./routes/shopping');
|
||||||
|
const calendarRoutes = require('./routes/calendar');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -15,13 +16,15 @@ app.use(express.urlencoded({ extended: true }));
|
|||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/shopping', shoppingRoutes);
|
app.use('/api/shopping', shoppingRoutes);
|
||||||
|
app.use('/api/calendar', calendarRoutes);
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
message: 'Shopping List Server is running',
|
message: 'OpenWall Server is running',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
|
features: ['shopping', 'calendar']
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,9 +55,10 @@ const startServer = async () => {
|
|||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Shopping List Server is running on port ${PORT}`);
|
console.log(`OpenWall Server is running on port ${PORT}`);
|
||||||
console.log(`Health check available at: http://localhost:${PORT}/api/health`);
|
console.log(`Health check available at: http://localhost:${PORT}/api/health`);
|
||||||
console.log(`Shopping API available at: http://localhost:${PORT}/api/shopping`);
|
console.log(`Shopping API available at: http://localhost:${PORT}/api/shopping`);
|
||||||
|
console.log(`Calendar API available at: http://localhost:${PORT}/api/calendar`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Unable to start server:', error);
|
console.error('Unable to start server:', error);
|
||||||
|
41
server/models/CalendarUser.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
|
||||||
|
module.exports = (sequelize) => {
|
||||||
|
const CalendarUser = sequelize.define('CalendarUser', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
nextcloudUrl: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
calendarName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true // Will default to personal calendar if not specified
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'calendar_users',
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return CalendarUser;
|
||||||
|
};
|
@@ -4,39 +4,41 @@ const path = require('path');
|
|||||||
// Initialize Sequelize with SQLite
|
// Initialize Sequelize with SQLite
|
||||||
const sequelize = new Sequelize({
|
const sequelize = new Sequelize({
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
storage: path.join(__dirname, '../database.sqlite'),
|
storage: path.join(__dirname, '..', 'database.sqlite'),
|
||||||
logging: false, // Set to console.log to see SQL queries
|
logging: process.env.NODE_ENV === 'development' ? console.log : false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import models
|
// Import and initialize models
|
||||||
const ShoppingItem = require('./ShoppingItem')(sequelize);
|
const createShoppingItem = require('./ShoppingItem');
|
||||||
|
const createCalendarUser = require('./CalendarUser');
|
||||||
|
|
||||||
// Function to clean up checked items older than 2 hours
|
const ShoppingItem = createShoppingItem(sequelize);
|
||||||
|
const CalendarUser = createCalendarUser(sequelize);
|
||||||
|
|
||||||
|
// Function to clean up checked shopping items older than 24 hours
|
||||||
const cleanupCheckedItems = async () => {
|
const cleanupCheckedItems = async () => {
|
||||||
try {
|
try {
|
||||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
const deletedCount = await ShoppingItem.destroy({
|
const deletedCount = await ShoppingItem.destroy({
|
||||||
where: {
|
where: {
|
||||||
checked: true,
|
checked: true,
|
||||||
checkedAt: {
|
updatedAt: {
|
||||||
[Sequelize.Op.lt]: twoHoursAgo,
|
[Sequelize.Op.lt]: oneDayAgo
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deletedCount > 0) {
|
if (deletedCount > 0) {
|
||||||
console.log(`Cleaned up ${deletedCount} checked items older than 2 hours`);
|
console.log(`Cleaned up ${deletedCount} checked shopping items older than 24 hours`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cleaning up checked items:', error);
|
console.error('Error during cleanup:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run cleanup every 30 minutes
|
|
||||||
setInterval(cleanupCheckedItems, 30 * 60 * 1000);
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sequelize,
|
sequelize,
|
||||||
ShoppingItem,
|
ShoppingItem,
|
||||||
cleanupCheckedItems,
|
CalendarUser,
|
||||||
|
cleanupCheckedItems
|
||||||
};
|
};
|
||||||
|
31
server/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nextcloud/cdav-library": "^1.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@@ -26,6 +27,19 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@nextcloud/cdav-library": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nextcloud/cdav-library/-/cdav-library-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-hmJgR9Cp11y3ch4dS0NufsPgofe4+iwhUkusYKmDTl0PFsJrBUNy1zawLdfDrpEjK1zXrU3tOpyF3pIqyGMYBg==",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"dependencies": {
|
||||||
|
"core-js": "^3.19.3",
|
||||||
|
"regenerator-runtime": "^0.13.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@npmcli/fs": {
|
"node_modules/@npmcli/fs": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
|
||||||
@@ -501,6 +515,17 @@
|
|||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz",
|
||||||
|
"integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cors": {
|
"node_modules/cors": {
|
||||||
"version": "2.8.5",
|
"version": "2.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||||
@@ -1979,6 +2004,12 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/retry": {
|
"node_modules/retry": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||||
|
@@ -13,6 +13,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@10.13.1",
|
"packageManager": "pnpm@10.13.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nextcloud/cdav-library": "^1.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
403
server/routes/calendar.js
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { CalendarUser } = require('../models');
|
||||||
|
const CalendarService = require('../services/CalendarService');
|
||||||
|
|
||||||
|
// Get all calendar users
|
||||||
|
router.get('/users', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = await CalendarUser.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
attributes: ['id', 'name', 'nextcloudUrl', 'username', 'calendarName', 'isActive']
|
||||||
|
});
|
||||||
|
res.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching calendar users:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch calendar users' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a new calendar user
|
||||||
|
router.post('/users', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, nextcloudUrl, username, password, calendarName } = req.body;
|
||||||
|
|
||||||
|
if (!name || !nextcloudUrl || !username || !password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields: name, nextcloudUrl, username, password'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection before saving
|
||||||
|
const testConfig = { name, nextcloudUrl, username, password, calendarName };
|
||||||
|
const connectionTest = await CalendarService.testConnection(testConfig);
|
||||||
|
|
||||||
|
if (!connectionTest) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Failed to connect to Nextcloud instance. Please check your credentials.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await CalendarUser.create({
|
||||||
|
name,
|
||||||
|
nextcloudUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
calendarName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return user without password
|
||||||
|
const { password: _, ...userWithoutPassword } = user.toJSON();
|
||||||
|
res.status(201).json(userWithoutPassword);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating calendar user:', error);
|
||||||
|
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||||
|
res.status(400).json({ error: 'User name already exists' });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: 'Failed to create calendar user' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a calendar user
|
||||||
|
router.put('/users/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, nextcloudUrl, username, password, calendarName, isActive } = req.body;
|
||||||
|
|
||||||
|
const user = await CalendarUser.findByPk(id);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'Calendar user not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection if credentials are being updated
|
||||||
|
if (nextcloudUrl || username || password) {
|
||||||
|
const testConfig = {
|
||||||
|
name: name || user.name,
|
||||||
|
nextcloudUrl: nextcloudUrl || user.nextcloudUrl,
|
||||||
|
username: username || user.username,
|
||||||
|
password: password || user.password,
|
||||||
|
calendarName: calendarName || user.calendarName
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionTest = await CalendarService.testConnection(testConfig);
|
||||||
|
if (!connectionTest) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Failed to connect to Nextcloud instance. Please check your credentials.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.update({
|
||||||
|
...(name && { name }),
|
||||||
|
...(nextcloudUrl && { nextcloudUrl }),
|
||||||
|
...(username && { username }),
|
||||||
|
...(password && { password }),
|
||||||
|
...(calendarName !== undefined && { calendarName }),
|
||||||
|
...(isActive !== undefined && { isActive })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return user without password
|
||||||
|
const { password: _, ...userWithoutPassword } = user.toJSON();
|
||||||
|
res.json(userWithoutPassword);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating calendar user:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update calendar user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a calendar user
|
||||||
|
router.delete('/users/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const user = await CalendarUser.findByPk(id);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'Calendar user not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.destroy();
|
||||||
|
res.json({ message: 'Calendar user deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting calendar user:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete calendar user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get calendar events for a specific month
|
||||||
|
// GET /api/calendar/events/:year/:month?user=username&type=family|individual
|
||||||
|
router.get('/events/:year/:month', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month } = req.params;
|
||||||
|
const { user, type } = req.query;
|
||||||
|
|
||||||
|
// Validate year and month
|
||||||
|
if (!year || !month || !/^\d{4}$/.test(year) || !/^(0[1-9]|1[0-2])$/.test(month)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid year or month format. Use YYYY for year and MM for month.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'family') {
|
||||||
|
// Get events from all active users
|
||||||
|
const users = await CalendarUser.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Sequelize instances to plain objects
|
||||||
|
const userConfigs = users.map(user => user.toJSON());
|
||||||
|
|
||||||
|
const events = await CalendarService.getFamilyEventsForMonth(userConfigs, year, month);
|
||||||
|
res.json(events);
|
||||||
|
} else {
|
||||||
|
// Get events for a specific user
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'User parameter required for individual calendar view'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userConfig = await CalendarUser.findOne({
|
||||||
|
where: { name: user, isActive: true },
|
||||||
|
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userConfig) {
|
||||||
|
return res.status(404).json({ error: 'User not found or inactive' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Sequelize instance to plain object
|
||||||
|
const userConfigPlain = userConfig.toJSON();
|
||||||
|
|
||||||
|
const events = await CalendarService.getEventsForMonth(userConfigPlain, year, month);
|
||||||
|
const birthdayEvents = await CalendarService.getContactBirthdaysForMonth(userConfigPlain, year, month);
|
||||||
|
|
||||||
|
// Combine regular events and birthday events
|
||||||
|
const allEvents = [...events, ...birthdayEvents];
|
||||||
|
|
||||||
|
// Sort events by date string first (YYYY-MM-DD format) - this ensures proper chronological ordering
|
||||||
|
allEvents.sort((a, b) => {
|
||||||
|
// First, compare dates as strings to avoid timezone issues
|
||||||
|
if (a.date !== b.date) {
|
||||||
|
return a.date.localeCompare(b.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dates are the same, sort by type (birthdays first)
|
||||||
|
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
|
||||||
|
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
|
||||||
|
|
||||||
|
// Then sort by event text for consistent ordering
|
||||||
|
return (a.text || '').localeCompare(b.text || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(allEvents);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving calendar events:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to retrieve calendar events' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new calendar event
|
||||||
|
// POST /api/calendar/events
|
||||||
|
router.post('/events', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user, date, text } = req.body;
|
||||||
|
|
||||||
|
if (!user || !date || !text) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields: user, date, text'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format (YYYY-MM-DD)
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid date format. Use YYYY-MM-DD.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userConfig = await CalendarUser.findOne({
|
||||||
|
where: { name: user, isActive: true },
|
||||||
|
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userConfig) {
|
||||||
|
return res.status(404).json({ error: 'User not found or inactive' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await CalendarService.createEvent(userConfig.toJSON(), date, text);
|
||||||
|
res.status(201).json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating calendar event:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to create calendar event' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a calendar event
|
||||||
|
// PUT /api/calendar/events/:eventId
|
||||||
|
router.put('/events/:eventId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const { user, date, text } = req.body;
|
||||||
|
|
||||||
|
if (!user || !date || !text) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields: user, date, text'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format (YYYY-MM-DD)
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid date format. Use YYYY-MM-DD.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userConfig = await CalendarUser.findOne({
|
||||||
|
where: { name: user, isActive: true },
|
||||||
|
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userConfig) {
|
||||||
|
return res.status(404).json({ error: 'User not found or inactive' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await CalendarService.updateEvent(userConfig.toJSON(), eventId, date, text);
|
||||||
|
res.json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating calendar event:', error);
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
res.status(404).json({ error: 'Event not found' });
|
||||||
|
} else if (error.message.includes('You can only edit events that belong to you')) {
|
||||||
|
res.status(403).json({ error: 'You can only edit events that belong to you' });
|
||||||
|
} else if (error.message.includes('Permission denied')) {
|
||||||
|
res.status(403).json({ error: error.message });
|
||||||
|
} else if (error.message.includes('CalDAV authentication failed')) {
|
||||||
|
res.status(401).json({ error: 'Calendar authentication failed' });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: 'Failed to update calendar event' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a calendar event
|
||||||
|
// DELETE /api/calendar/events/:eventId
|
||||||
|
router.delete('/events/:eventId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const { user } = req.body;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required field: user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a birthday event (birthday events should not be deletable)
|
||||||
|
if (eventId && eventId.includes('birthday-')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Birthday events cannot be deleted from the calendar interface'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userConfig = await CalendarUser.findOne({
|
||||||
|
where: { name: user, isActive: true },
|
||||||
|
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userConfig) {
|
||||||
|
return res.status(404).json({ error: 'User not found or inactive' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await CalendarService.deleteEvent(userConfig.toJSON(), eventId);
|
||||||
|
res.json({ message: 'Event deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting calendar event:', error);
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Event not found. This event may belong to a different user or have already been deleted.'
|
||||||
|
});
|
||||||
|
} else if (error.message.includes('You can only delete events that belong to you')) {
|
||||||
|
res.status(403).json({ error: 'You can only delete events that belong to you' });
|
||||||
|
} else if (error.message === 'Birthday events cannot be deleted from the calendar interface') {
|
||||||
|
res.status(403).json({ error: error.message });
|
||||||
|
} else if (error.message.includes('Permission denied') || error.message.includes('authentication failed')) {
|
||||||
|
res.status(403).json({ error: 'Permission denied. You may not have access to delete this event.' });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: 'Failed to delete calendar event' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Nextcloud connection for a user
|
||||||
|
router.post('/users/:id/test-connection', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const user = await CalendarUser.findByPk(id);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'Calendar user not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionTest = await CalendarService.testConnection({
|
||||||
|
name: user.name,
|
||||||
|
nextcloudUrl: user.nextcloudUrl,
|
||||||
|
username: user.username,
|
||||||
|
password: user.password,
|
||||||
|
calendarName: user.calendarName
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: connectionTest,
|
||||||
|
message: connectionTest ? 'Connection successful' : 'Connection failed'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing connection:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to test connection' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get contact birthdays for a specific month
|
||||||
|
// GET /api/calendar/birthdays/:year/:month?user=username
|
||||||
|
router.get('/birthdays/:year/:month', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { year, month } = req.params;
|
||||||
|
const { user } = req.query;
|
||||||
|
|
||||||
|
// Validate year and month
|
||||||
|
if (!year || !month || !/^\d{4}$/.test(year) || !/^(0[1-9]|1[0-2])$/.test(month)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid year or month format. Use YYYY for year and MM for month.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'User parameter required for birthday calendar access'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userConfig = await CalendarUser.findOne({
|
||||||
|
where: { name: user, isActive: true },
|
||||||
|
attributes: ['name', 'nextcloudUrl', 'username', 'password', 'calendarName']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userConfig) {
|
||||||
|
return res.status(404).json({ error: 'User not found or inactive' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const birthdayEvents = await CalendarService.getContactBirthdaysForMonth(userConfig.toJSON(), year, month);
|
||||||
|
res.json(birthdayEvents);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving contact birthdays:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to retrieve contact birthdays' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
970
server/services/CalendarService.js
Normal file
@@ -0,0 +1,970 @@
|
|||||||
|
// Calendar Service for Nextcloud CalDAV integration
|
||||||
|
// This is a basic implementation that can be enhanced with the @nextcloud/cdav-library
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
class CalendarService {
|
||||||
|
/**
|
||||||
|
* Create a calendar event
|
||||||
|
* @param {Object} userConfig - Nextcloud user configuration
|
||||||
|
* @param {string} date - Date in YYYY-MM-DD format
|
||||||
|
* @param {string} text - Event description
|
||||||
|
* @returns {Promise<Object>} Created event
|
||||||
|
*/
|
||||||
|
async createEvent(userConfig, date, text) {
|
||||||
|
try {
|
||||||
|
// Validate input parameters
|
||||||
|
if (!userConfig) {
|
||||||
|
throw new Error('User configuration is required');
|
||||||
|
}
|
||||||
|
if (!userConfig.name) {
|
||||||
|
throw new Error('User name is required');
|
||||||
|
}
|
||||||
|
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
throw new Error('Valid date in YYYY-MM-DD format is required');
|
||||||
|
}
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
throw new Error('Event text is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate event ID and structure
|
||||||
|
const eventId = crypto.randomUUID();
|
||||||
|
const event = {
|
||||||
|
id: eventId,
|
||||||
|
date: date,
|
||||||
|
text: text.trim(),
|
||||||
|
user: userConfig.name,
|
||||||
|
summary: text.trim(),
|
||||||
|
dtstart: this._formatDateForCalDAV(date),
|
||||||
|
dtend: this._formatDateForCalDAV(date),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create event via CalDAV
|
||||||
|
const caldavEvent = await this._createCalDAVEvent(userConfig, event);
|
||||||
|
|
||||||
|
return caldavEvent;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to create calendar event: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get calendar events for a specific month
|
||||||
|
* @param {Object} userConfig - Nextcloud user configuration
|
||||||
|
* @param {string} year - Year (YYYY)
|
||||||
|
* @param {string} month - Month (MM)
|
||||||
|
* @returns {Promise<Array>} Array of events
|
||||||
|
*/
|
||||||
|
async getEventsForMonth(userConfig, year, month) {
|
||||||
|
try {
|
||||||
|
// Validate input parameters
|
||||||
|
if (!userConfig || !userConfig.name) {
|
||||||
|
throw new Error('Valid user configuration is required');
|
||||||
|
}
|
||||||
|
if (!year || !/^\d{4}$/.test(year)) {
|
||||||
|
throw new Error('Valid year in YYYY format is required');
|
||||||
|
}
|
||||||
|
if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) {
|
||||||
|
throw new Error('Valid month in MM format is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events from CalDAV
|
||||||
|
const events = await this._fetchCalDAVEvents(userConfig, year, month);
|
||||||
|
|
||||||
|
return events;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to retrieve calendar events: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contact birthdays for a specific month
|
||||||
|
* @param {Object} userConfig - Nextcloud user configuration
|
||||||
|
* @param {string} year - Year (YYYY)
|
||||||
|
* @param {string} month - Month (MM)
|
||||||
|
* @returns {Promise<Array>} Array of birthday events
|
||||||
|
*/
|
||||||
|
async getContactBirthdaysForMonth(userConfig, year, month) {
|
||||||
|
try {
|
||||||
|
// Validate input parameters
|
||||||
|
if (!userConfig || !userConfig.name) {
|
||||||
|
console.warn('Valid user configuration is required for birthday fetch');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) {
|
||||||
|
console.warn(`Missing required connection details for ${userConfig.name} birthday calendar`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!year || !/^\d{4}$/.test(year)) {
|
||||||
|
throw new Error('Valid year in YYYY format is required');
|
||||||
|
}
|
||||||
|
if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) {
|
||||||
|
throw new Error('Valid month in MM format is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a separate config for the contact_birthdays calendar
|
||||||
|
const birthdayConfig = {
|
||||||
|
name: userConfig.name,
|
||||||
|
nextcloudUrl: userConfig.nextcloudUrl,
|
||||||
|
username: userConfig.username,
|
||||||
|
password: userConfig.password,
|
||||||
|
calendarName: 'contact_birthdays'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch birthday events from the contact_birthdays calendar
|
||||||
|
const birthdayEvents = await this._fetchCalDAVEvents(birthdayConfig, year, month);
|
||||||
|
|
||||||
|
// Mark these events as birthday events and add additional properties
|
||||||
|
const processedBirthdayEvents = birthdayEvents.map(event => ({
|
||||||
|
...event,
|
||||||
|
id: `birthday-${event.id}`, // Prefix birthday event IDs to make them identifiable
|
||||||
|
type: 'birthday',
|
||||||
|
isBirthday: true,
|
||||||
|
source: 'contact_birthdays',
|
||||||
|
// Extract contact name from birthday text if possible
|
||||||
|
contactName: this._extractContactNameFromBirthday(event.text || event.summary)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return processedBirthdayEvents;
|
||||||
|
} catch (error) {
|
||||||
|
// If contact_birthdays calendar doesn't exist or is inaccessible, return empty array
|
||||||
|
console.warn(`Could not fetch contact birthdays for ${userConfig.name}: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to retrieve contact birthdays: ${error.message}`);
|
||||||
|
return []; // Return empty array instead of throwing to not break other calendar functionality
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get calendar events for all users (family view)
|
||||||
|
* @param {Array} userConfigs - Array of user configurations
|
||||||
|
* @param {string} year - Year (YYYY)
|
||||||
|
* @param {string} month - Month (MM)
|
||||||
|
* @returns {Promise<Array>} Array of events from all users
|
||||||
|
*/
|
||||||
|
async getFamilyEventsForMonth(userConfigs, year, month) {
|
||||||
|
try {
|
||||||
|
const allEvents = [];
|
||||||
|
|
||||||
|
for (const userConfig of userConfigs) {
|
||||||
|
const userEvents = await this.getEventsForMonth(userConfig, year, month);
|
||||||
|
allEvents.push(...userEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch contact birthdays only from the first user to avoid duplicates
|
||||||
|
// Birthday calendars are typically shared/synchronized across users
|
||||||
|
if (userConfigs.length > 0) {
|
||||||
|
const birthdayEvents = await this.getContactBirthdaysForMonth(userConfigs[0], year, month);
|
||||||
|
|
||||||
|
// Filter out duplicate birthday events and merge with regular events that might be on the same day
|
||||||
|
const seenBirthdays = new Set();
|
||||||
|
const uniqueBirthdayEvents = birthdayEvents.filter(event => {
|
||||||
|
const birthdayKey = `${event.date}-${(event.text || '').trim()}-${(event.contactName || '').trim()}`;
|
||||||
|
if (!seenBirthdays.has(birthdayKey)) {
|
||||||
|
seenBirthdays.add(birthdayKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).map(event => ({
|
||||||
|
...event,
|
||||||
|
// Use the first user's name for birthday events or mark as shared
|
||||||
|
user: userConfigs[0].name,
|
||||||
|
isSharedBirthday: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
allEvents.push(...uniqueBirthdayEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort events by date string first (YYYY-MM-DD format) - this ensures proper chronological ordering
|
||||||
|
allEvents.sort((a, b) => {
|
||||||
|
// First, compare dates as strings to avoid timezone issues
|
||||||
|
if (a.date !== b.date) {
|
||||||
|
return a.date.localeCompare(b.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dates are the same, sort by type (birthdays first)
|
||||||
|
if (a.type === 'birthday' && b.type !== 'birthday') return -1;
|
||||||
|
if (a.type !== 'birthday' && b.type === 'birthday') return 1;
|
||||||
|
|
||||||
|
// Then sort by event text for consistent ordering
|
||||||
|
return (a.text || '').localeCompare(b.text || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return allEvents;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving family calendar events:', error);
|
||||||
|
throw new Error('Failed to retrieve family calendar events');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a calendar event
|
||||||
|
* @param {Object} userConfig - Nextcloud user configuration
|
||||||
|
* @param {string} eventId - Event ID
|
||||||
|
* @param {string} date - New date
|
||||||
|
* @param {string} text - New text
|
||||||
|
* @returns {Promise<Object>} Updated event
|
||||||
|
*/
|
||||||
|
async updateEvent(userConfig, eventId, date, text) {
|
||||||
|
try {
|
||||||
|
// Build updated event object
|
||||||
|
const event = {
|
||||||
|
id: eventId,
|
||||||
|
date: date,
|
||||||
|
text: text,
|
||||||
|
user: userConfig.name,
|
||||||
|
summary: text,
|
||||||
|
dtstart: this._formatDateForCalDAV(date),
|
||||||
|
dtend: this._formatDateForCalDAV(date),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update event via CalDAV - let the server handle existence validation
|
||||||
|
const updatedEvent = await this._updateCalDAVEvent(userConfig, event);
|
||||||
|
|
||||||
|
return updatedEvent;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a calendar event
|
||||||
|
* @param {Object} userConfig - Nextcloud user configuration
|
||||||
|
* @param {string} eventId - Event ID
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async deleteEvent(userConfig, eventId) {
|
||||||
|
try {
|
||||||
|
// Check if this is a birthday event that shouldn't be deleted
|
||||||
|
if (eventId && eventId.includes('birthday-')) {
|
||||||
|
console.warn(`Blocked attempt to delete birthday event ${eventId}`);
|
||||||
|
throw new Error('Birthday events cannot be deleted from the calendar interface');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simply attempt the deletion - let CalDAV server handle existence validation
|
||||||
|
// The previous approach of checking multiple months was too complex and unreliable
|
||||||
|
await this._deleteCalDAVEvent(userConfig, eventId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete event ${eventId} for user ${userConfig.name}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Nextcloud instance
|
||||||
|
* @param {Object} userConfig - Nextcloud user configuration
|
||||||
|
* @returns {Promise<boolean>} Connection status
|
||||||
|
*/
|
||||||
|
async testConnection(userConfig) {
|
||||||
|
try {
|
||||||
|
// Validate user configuration
|
||||||
|
if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic HTTP test to verify Nextcloud instance is reachable
|
||||||
|
const url = new URL(userConfig.nextcloudUrl);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: '/status.php',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 5000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'OpenWall/1.0'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
const result = await new Promise((resolve) => {
|
||||||
|
const req = protocol.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const status = JSON.parse(data);
|
||||||
|
const isNextcloud = status.productname && status.productname.toLowerCase().includes('nextcloud');
|
||||||
|
resolve(isNextcloud);
|
||||||
|
} catch (e) {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// If basic connection works, test CalDAV endpoint
|
||||||
|
if (result) {
|
||||||
|
const caldavResult = await this._testCalDAVConnection(userConfig);
|
||||||
|
return caldavResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for CalDAV (YYYYMMDD format for all-day events)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_formatDateForCalDAV(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test CalDAV connection by trying to access the calendar endpoint
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _testCalDAVConnection(userConfig) {
|
||||||
|
try {
|
||||||
|
// Try to access the CalDAV principal endpoint
|
||||||
|
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||||
|
|
||||||
|
const url = new URL(caldavUrl);
|
||||||
|
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'PROPFIND',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'Depth': '0',
|
||||||
|
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:displayname/>
|
||||||
|
<C:calendar-description/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>`;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = protocol.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build CalDAV URL for a user
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_buildCalDAVUrl(userConfig) {
|
||||||
|
try {
|
||||||
|
// Validate userConfig
|
||||||
|
if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) {
|
||||||
|
throw new Error('Invalid user configuration: missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash from nextcloudUrl if present
|
||||||
|
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Build CalDAV principal URL - this is the standard Nextcloud CalDAV path
|
||||||
|
const calendarName = userConfig.calendarName || 'personal';
|
||||||
|
return `${baseUrl}/remote.php/dav/calendars/${userConfig.username}/${calendarName}/`;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to build CalDAV URL: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build CalDAV principals URL for a user
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_buildCalDAVPrincipalsUrl(userConfig) {
|
||||||
|
try {
|
||||||
|
// Validate userConfig
|
||||||
|
if (!userConfig || !userConfig.nextcloudUrl || !userConfig.username) {
|
||||||
|
throw new Error('Invalid user configuration: missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, '');
|
||||||
|
return `${baseUrl}/remote.php/dav/principals/users/${userConfig.username}/`;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to build CalDAV principals URL: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a CalDAV event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _createCalDAVEvent(userConfig, event) {
|
||||||
|
try {
|
||||||
|
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||||
|
const eventUrl = `${caldavUrl}${event.id}.ics`;
|
||||||
|
|
||||||
|
const url = new URL(eventUrl);
|
||||||
|
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||||
|
|
||||||
|
const icsContent = this._generateICSEvent(event);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'PUT',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'text/calendar; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(icsContent, 'utf8'),
|
||||||
|
'User-Agent': 'OpenWall/1.0 CalDAV-Client',
|
||||||
|
'If-None-Match': '*' // Prevent overwriting existing events
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = protocol.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(event);
|
||||||
|
} else if (res.statusCode === 401) {
|
||||||
|
reject(new Error('CalDAV authentication failed'));
|
||||||
|
} else if (res.statusCode === 412) {
|
||||||
|
reject(new Error('CalDAV event already exists'));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`CalDAV event creation failed with status ${res.statusCode}: ${data}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(new Error(`CalDAV request failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('CalDAV request timeout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(icsContent);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch CalDAV events
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _fetchCalDAVEvents(userConfig, year, month) {
|
||||||
|
try {
|
||||||
|
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||||
|
|
||||||
|
const url = new URL(caldavUrl);
|
||||||
|
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||||
|
|
||||||
|
// Build date range for the month
|
||||||
|
const startDate = new Date(parseInt(year), parseInt(month) - 1, 1);
|
||||||
|
const endDate = new Date(parseInt(year), parseInt(month), 0);
|
||||||
|
|
||||||
|
const startDateStr = startDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T000000Z';
|
||||||
|
const endDateStr = endDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T235959Z';
|
||||||
|
|
||||||
|
const reportBody = `<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag/>
|
||||||
|
<C:calendar-data/>
|
||||||
|
</D:prop>
|
||||||
|
<C:filter>
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:time-range start="${startDateStr}" end="${endDateStr}"/>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:filter>
|
||||||
|
</C:calendar-query>`;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'REPORT',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(reportBody, 'utf8'),
|
||||||
|
'Depth': '1',
|
||||||
|
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = protocol.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
try {
|
||||||
|
const events = this._parseCalDAVResponse(data, userConfig, year, month);
|
||||||
|
resolve(events);
|
||||||
|
} catch (parseError) {
|
||||||
|
reject(parseError);
|
||||||
|
}
|
||||||
|
} else if (res.statusCode === 401) {
|
||||||
|
reject(new Error('CalDAV authentication failed'));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`CalDAV event fetch failed with status ${res.statusCode}: ${data}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(new Error(`CalDAV request failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('CalDAV request timeout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(reportBody);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse CalDAV response and extract events
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_parseCalDAVResponse(xmlData, userConfig, requestedYear, requestedMonth) {
|
||||||
|
try {
|
||||||
|
// This is a basic parser - in a real implementation, you'd use a proper XML parser
|
||||||
|
const events = [];
|
||||||
|
|
||||||
|
// Look for calendar-data with different possible namespace prefixes
|
||||||
|
const eventMatches = xmlData.match(/<(?:C:|cal:)?calendar-data[^>]*>([\s\S]*?)<\/(?:C:|cal:)?calendar-data>/gi);
|
||||||
|
|
||||||
|
if (eventMatches) {
|
||||||
|
eventMatches.forEach((match, index) => {
|
||||||
|
try {
|
||||||
|
// Extract the VCALENDAR content - handle CDATA and escaped content
|
||||||
|
let icsContent = match
|
||||||
|
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1') // Remove CDATA wrapper
|
||||||
|
.replace(/<[^>]*>/g, '') // Remove XML tags
|
||||||
|
.replace(/</g, '<') // Decode HTML entities
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const event = this._parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth);
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
} catch (eventError) {
|
||||||
|
// Skip individual events that fail to parse
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse CalDAV response: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ICS event content
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_parseICSEvent(icsContent, userConfig, requestedYear, requestedMonth) {
|
||||||
|
try {
|
||||||
|
const lines = icsContent.split('\n').map(line => line.trim()).filter(line => line);
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
user: userConfig.name,
|
||||||
|
source: 'caldav'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('UID:')) {
|
||||||
|
event.id = line.substring(4);
|
||||||
|
} else if (line.startsWith('SUMMARY:')) {
|
||||||
|
event.text = line.substring(8);
|
||||||
|
event.summary = event.text;
|
||||||
|
} else if (line.startsWith('DTSTART')) {
|
||||||
|
const dateValue = line.split(':')[1];
|
||||||
|
event.dtstart = dateValue;
|
||||||
|
// Parse date more robustly
|
||||||
|
if (dateValue && dateValue.length >= 8) {
|
||||||
|
// Handle different DTSTART formats: YYYYMMDD, YYYYMMDDTHHMMSSZ, etc.
|
||||||
|
const dateOnly = dateValue.substring(0, 8);
|
||||||
|
if (/^\d{8}$/.test(dateOnly)) {
|
||||||
|
const year = parseInt(dateOnly.substring(0, 4));
|
||||||
|
const month = parseInt(dateOnly.substring(4, 6));
|
||||||
|
const day = parseInt(dateOnly.substring(6, 8));
|
||||||
|
|
||||||
|
// Validate month and day are reasonable (don't restrict year for recurring events)
|
||||||
|
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
||||||
|
// Create date and validate it's correct
|
||||||
|
const testDate = new Date(year, month - 1, day);
|
||||||
|
if (!isNaN(testDate.getTime()) &&
|
||||||
|
testDate.getMonth() === (month - 1) &&
|
||||||
|
testDate.getDate() === day) {
|
||||||
|
event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||||
|
event.originalYear = year;
|
||||||
|
event.originalDate = event.date;
|
||||||
|
} else {
|
||||||
|
// If date validation fails, try with current year
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const fallbackDate = new Date(currentYear, month - 1, day);
|
||||||
|
if (!isNaN(fallbackDate.getTime())) {
|
||||||
|
event.date = `${currentYear}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||||
|
event.originalYear = year;
|
||||||
|
event.isFallback = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (line.startsWith('DTEND')) {
|
||||||
|
const dateValue = line.split(':')[1];
|
||||||
|
event.dtend = dateValue;
|
||||||
|
// If we don't have a start date yet, try to get it from end date
|
||||||
|
if (!event.date && dateValue && dateValue.length >= 8) {
|
||||||
|
const dateOnly = dateValue.substring(0, 8);
|
||||||
|
if (/^\d{8}$/.test(dateOnly)) {
|
||||||
|
const year = parseInt(dateOnly.substring(0, 4));
|
||||||
|
const month = parseInt(dateOnly.substring(4, 6));
|
||||||
|
const day = parseInt(dateOnly.substring(6, 8));
|
||||||
|
|
||||||
|
// Validate month and day are reasonable (don't restrict year for recurring events)
|
||||||
|
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
||||||
|
// Create date and validate it's correct
|
||||||
|
const testDate = new Date(year, month - 1, day);
|
||||||
|
if (!isNaN(testDate.getTime()) &&
|
||||||
|
testDate.getMonth() === (month - 1) &&
|
||||||
|
testDate.getDate() === day) {
|
||||||
|
event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||||
|
event.originalYear = year;
|
||||||
|
event.originalDate = event.date;
|
||||||
|
} else {
|
||||||
|
// If date validation fails, try with current year
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const fallbackDate = new Date(currentYear, month - 1, day);
|
||||||
|
if (!isNaN(fallbackDate.getTime())) {
|
||||||
|
event.date = `${currentYear}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
|
||||||
|
event.originalYear = year;
|
||||||
|
event.isFallback = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (line.startsWith('CREATED:')) {
|
||||||
|
event.createdAt = line.substring(8);
|
||||||
|
} else if (line.startsWith('LAST-MODIFIED:')) {
|
||||||
|
event.updatedAt = line.substring(14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize date to the requested year/month for proper grouping
|
||||||
|
if (event.date && requestedYear && requestedMonth) {
|
||||||
|
const eventDate = new Date(event.date);
|
||||||
|
|
||||||
|
// Validate that the date parsed correctly
|
||||||
|
if (!isNaN(eventDate.getTime())) {
|
||||||
|
const eventDay = eventDate.getDate();
|
||||||
|
|
||||||
|
// Validate day is reasonable for the month
|
||||||
|
if (eventDay >= 1 && eventDay <= 31) {
|
||||||
|
// Always use the requested year for proper grouping, regardless of original event year
|
||||||
|
const normalizedDate = `${requestedYear}-${requestedMonth.padStart(2, '0')}-${eventDay.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Validate the normalized date is valid
|
||||||
|
const testNormalizedDate = new Date(normalizedDate);
|
||||||
|
if (!isNaN(testNormalizedDate.getTime())) {
|
||||||
|
event.date = normalizedDate;
|
||||||
|
event.isNormalized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return events with required fields and valid dates
|
||||||
|
if (event.id && event.text && event.date) {
|
||||||
|
return event;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a calendar event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _updateCalDAVEvent(userConfig, event) {
|
||||||
|
try {
|
||||||
|
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||||
|
const eventUrl = `${caldavUrl}${event.id}.ics`;
|
||||||
|
|
||||||
|
const url = new URL(eventUrl);
|
||||||
|
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||||
|
|
||||||
|
const icsContent = this._generateICSEvent(event);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'PUT',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'text/calendar; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(icsContent, 'utf8'),
|
||||||
|
'User-Agent': 'OpenWall/1.0 CalDAV-Client',
|
||||||
|
'If-Match': '*' // Allow overwriting existing events
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = protocol.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(event);
|
||||||
|
} else if (res.statusCode === 401) {
|
||||||
|
reject(new Error('CalDAV authentication failed'));
|
||||||
|
} else if (res.statusCode === 404) {
|
||||||
|
reject(new Error('Event not found'));
|
||||||
|
} else if (res.statusCode === 400 && data.includes('uid already exists')) {
|
||||||
|
// If UID conflict, try to delete and recreate
|
||||||
|
this._deleteCalDAVEvent(userConfig, event.id)
|
||||||
|
.then(() => this._createCalDAVEvent(userConfig, event))
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
reject(new Error(`CalDAV event update failed with status ${res.statusCode}: ${data}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(new Error(`CalDAV request failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('CalDAV request timeout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(icsContent);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a CalDAV event
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _deleteCalDAVEvent(userConfig, eventId) {
|
||||||
|
try {
|
||||||
|
const caldavUrl = this._buildCalDAVUrl(userConfig);
|
||||||
|
const eventUrl = `${caldavUrl}${eventId}.ics`;
|
||||||
|
|
||||||
|
const url = new URL(eventUrl);
|
||||||
|
const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'DELETE',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'User-Agent': 'OpenWall/1.0 CalDAV-Client'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocol = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = protocol.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(true);
|
||||||
|
} else if (res.statusCode === 401) {
|
||||||
|
reject(new Error('CalDAV authentication failed'));
|
||||||
|
} else if (res.statusCode === 404) {
|
||||||
|
reject(new Error('Event not found'));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`CalDAV event deletion failed with status ${res.statusCode}: ${data}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(new Error(`CalDAV request failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('CalDAV request timeout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate ICS event content
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_generateICSEvent(event) {
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
|
const created = event.createdAt ? new Date(event.createdAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now;
|
||||||
|
const modified = event.updatedAt ? new Date(event.updatedAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now;
|
||||||
|
|
||||||
|
return `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//OpenWall//Calendar//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:${event.id}
|
||||||
|
DTSTART;VALUE=DATE:${event.dtstart}
|
||||||
|
DTEND;VALUE=DATE:${event.dtend}
|
||||||
|
SUMMARY:${event.summary}
|
||||||
|
DESCRIPTION:${event.text}
|
||||||
|
CREATED:${created}
|
||||||
|
LAST-MODIFIED:${modified}
|
||||||
|
DTSTAMP:${now}
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract contact name from birthday event text
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_extractContactNameFromBirthday(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
// Common birthday text patterns in different languages
|
||||||
|
const patterns = [
|
||||||
|
/^(.+?)(?:'s|s)?\s*(?:Birthday|Geburtstag|birthday|geburtstag)$/i,
|
||||||
|
/^(?:Birthday|Geburtstag)\s*[:-]?\s*(.+)$/i,
|
||||||
|
/^(.+?)\s*\(.*\)$/i, // Remove parentheses content
|
||||||
|
/^(.+?)(?:\s*-\s*Birthday)?$/i
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no pattern matches, return the original text (minus common birthday words)
|
||||||
|
return text.replace(/\b(Birthday|Geburtstag|birthday|geburtstag)\b/gi, '').trim() || text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CalendarService();
|