From e0bea6c6e055aceecc3b29f3f5e4e5a2c273166b Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Fri, 24 Oct 2025 12:28:44 +0200 Subject: [PATCH 1/8] feat: Add comprehensive auto-sync functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ New Features: - Auto-sync service with configurable intervals (15min, 30min, 1hour, 6hours, 12hours, 24hours, custom cron) - Automatic JSON file synchronization from GitHub repositories - Auto-download new scripts when JSON files are updated - Auto-update existing scripts when newer versions are available - Apprise notification service integration for sync status updates - Comprehensive error handling and logging 🔧 Technical Implementation: - AutoSyncService: Core scheduling and execution logic - GitHubJsonService: Handles JSON file synchronization from GitHub - AppriseService: Sends notifications via multiple channels (Discord, Telegram, Email, Slack, etc.) - ScriptDownloaderService: Manages automatic script downloads and updates - Settings API: RESTful endpoints for auto-sync configuration - UI Integration: Settings modal with auto-sync configuration options 📋 Configuration Options: - Enable/disable auto-sync functionality - Flexible scheduling (predefined intervals or custom cron expressions) - Selective script processing (new downloads, updates, or both) - Notification settings with multiple Apprise URL support - Environment-based configuration with .env file persistence 🎯 Benefits: - Keeps script repository automatically synchronized - Reduces manual maintenance overhead - Provides real-time notifications of sync status - Supports multiple notification channels - Configurable to match different deployment needs This feature significantly enhances the automation capabilities of PVE Scripts Local, making it a truly hands-off solution for script management. --- package-lock.json | 174 ++++++- package.json | 5 + server.js | 11 + src/app/_components/GeneralSettingsModal.tsx | 416 ++++++++++++++++- src/app/_components/HelpModal.tsx | 100 +++- src/app/api/settings/auto-sync/route.ts | 362 +++++++++++++++ src/server/api/routers/scripts.ts | 102 +++++ src/server/lib/autoSyncInit.js | 65 +++ src/server/lib/autoSyncInit.ts | 65 +++ src/server/services/appriseService.js | 123 +++++ src/server/services/autoSyncService.js | 454 +++++++++++++++++++ src/server/services/githubJsonService.js | 271 +++++++++++ src/server/services/scriptDownloader.js | 343 ++++++++++++++ src/server/services/scriptDownloader.ts | 194 ++++++++ 14 files changed, 2664 insertions(+), 21 deletions(-) create mode 100644 src/app/api/settings/auto-sync/route.ts create mode 100644 src/server/lib/autoSyncInit.js create mode 100644 src/server/lib/autoSyncInit.ts create mode 100644 src/server/services/appriseService.js create mode 100644 src/server/services/autoSyncService.js create mode 100644 src/server/services/githubJsonService.js create mode 100644 src/server/services/scriptDownloader.js diff --git a/package-lock.json b/package-lock.json index 7d19f28..81368fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,16 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "axios": "^1.7.9", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cron-validator": "^1.2.0", + "dotenv": "^17.2.3", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.546.0", "next": "^15.5.6", + "node-cron": "^3.0.3", "node-pty": "^1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -52,6 +56,7 @@ "@types/better-sqlite3": "^7.6.8", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.9.1", + "@types/node-cron": "^3.0.11", "@types/react": "^19.0.0", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.2", @@ -3732,6 +3737,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.5", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", @@ -4884,6 +4896,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4910,6 +4928,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5059,6 +5088,19 @@ } } }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -5092,7 +5134,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5316,6 +5357,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -5372,6 +5425,12 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/cron-validator": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.4.0.tgz", + "integrity": "sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5623,6 +5682,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5690,10 +5758,9 @@ "peer": true }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5706,7 +5773,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5868,7 +5934,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5878,7 +5943,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5923,7 +5987,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5936,7 +5999,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6689,6 +6751,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -6722,6 +6804,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -6749,7 +6847,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6810,7 +6907,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6844,7 +6940,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -6997,7 +7092,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7076,7 +7170,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7089,7 +7182,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7105,7 +7197,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8654,7 +8745,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9536,6 +9626,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -9722,6 +9833,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -10386,6 +10509,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12504,6 +12633,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index d7bc330..e6299f5 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,16 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "axios": "^1.7.9", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cron-validator": "^1.2.0", + "dotenv": "^17.2.3", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.546.0", "next": "^15.5.6", + "node-cron": "^3.0.3", "node-pty": "^1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -66,6 +70,7 @@ "@types/better-sqlite3": "^7.6.8", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.9.1", + "@types/node-cron": "^3.0.11", "@types/react": "^19.0.0", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.0.2", diff --git a/server.js b/server.js index 112e780..0ca86bf 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,11 @@ import stripAnsi from 'strip-ansi'; import { spawn as ptySpawn } from 'node-pty'; import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; import { getDatabase } from './src/server/database-prisma.js'; +import { initializeAutoSync, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js'; +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); // Fallback minimal global error handlers for Node runtime (avoid TS import) function registerGlobalErrorHandlers() { if (registerGlobalErrorHandlers._registered) return; @@ -976,5 +981,11 @@ app.prepare().then(() => { .listen(port, hostname, () => { console.log(`> Ready on http://${hostname}:${port}`); console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`); + + // Initialize auto-sync service + initializeAutoSync(); + + // Setup graceful shutdown handlers + setupGracefulShutdown(); }); }); diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx index 364832a..d351709 100644 --- a/src/app/_components/GeneralSettingsModal.tsx +++ b/src/app/_components/GeneralSettingsModal.tsx @@ -7,6 +7,7 @@ import { Toggle } from './ui/toggle'; import { ContextualHelpIcon } from './ContextualHelpIcon'; import { useTheme } from './ThemeProvider'; import { useRegisterModal } from './modal/ModalStackProvider'; +import { api } from '~/trpc/react'; interface GeneralSettingsModalProps { isOpen: boolean; @@ -16,7 +17,7 @@ interface GeneralSettingsModalProps { export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) { useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose }); const { theme, setTheme } = useTheme(); - const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general'); const [githubToken, setGithubToken] = useState(''); const [saveFilter, setSaveFilter] = useState(false); const [savedFilters, setSavedFilters] = useState(null); @@ -34,6 +35,19 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr const [authSetupCompleted, setAuthSetupCompleted] = useState(false); const [authLoading, setAuthLoading] = useState(false); + // Auto-sync state + const [autoSyncEnabled, setAutoSyncEnabled] = useState(false); + const [syncIntervalType, setSyncIntervalType] = useState<'predefined' | 'custom'>('predefined'); + const [syncIntervalPredefined, setSyncIntervalPredefined] = useState('1hour'); + const [syncIntervalCron, setSyncIntervalCron] = useState(''); + const [autoDownloadNew, setAutoDownloadNew] = useState(false); + const [autoUpdateExisting, setAutoUpdateExisting] = useState(false); + const [notificationEnabled, setNotificationEnabled] = useState(false); + const [appriseUrls, setAppriseUrls] = useState([]); + const [appriseUrlsText, setAppriseUrlsText] = useState(''); + const [lastAutoSync, setLastAutoSync] = useState(''); + const [cronValidationError, setCronValidationError] = useState(''); + // Load existing settings when modal opens useEffect(() => { if (isOpen) { @@ -42,6 +56,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr void loadSavedFilters(); void loadAuthCredentials(); void loadColorCodingSetting(); + void loadAutoSyncSettings(); } }, [isOpen]); @@ -278,6 +293,162 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr } }; + // Auto-sync functions + const loadAutoSyncSettings = async () => { + try { + const response = await fetch('/api/settings/auto-sync'); + if (response.ok) { + const data = await response.json() as { settings: any }; + const settings = data.settings; + if (settings) { + setAutoSyncEnabled(settings.autoSyncEnabled ?? false); + setSyncIntervalType(settings.syncIntervalType ?? 'predefined'); + setSyncIntervalPredefined(settings.syncIntervalPredefined ?? '1hour'); + setSyncIntervalCron(settings.syncIntervalCron ?? ''); + setAutoDownloadNew(settings.autoDownloadNew ?? false); + setAutoUpdateExisting(settings.autoUpdateExisting ?? false); + setNotificationEnabled(settings.notificationEnabled ?? false); + setAppriseUrls(settings.appriseUrls ?? []); + setAppriseUrlsText((settings.appriseUrls ?? []).join('\n')); + setLastAutoSync(settings.lastAutoSync ?? ''); + } + } + } catch (error) { + console.error('Error loading auto-sync settings:', error); + } + }; + + const saveAutoSyncSettings = async () => { + setIsSaving(true); + setMessage(null); + + try { + // Validate cron expression if custom + if (syncIntervalType === 'custom' && syncIntervalCron) { + const response = await fetch('/api/settings/auto-sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + autoSyncEnabled, + syncIntervalType, + syncIntervalPredefined, + syncIntervalCron, + autoDownloadNew, + autoUpdateExisting, + notificationEnabled, + appriseUrls: appriseUrls + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + setMessage({ type: 'error', text: errorData.error ?? 'Failed to save auto-sync settings' }); + return; + } + } + + const response = await fetch('/api/settings/auto-sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + autoSyncEnabled, + syncIntervalType, + syncIntervalPredefined, + syncIntervalCron, + autoDownloadNew, + autoUpdateExisting, + notificationEnabled, + appriseUrls: appriseUrls + }) + }); + + if (response.ok) { + setMessage({ type: 'success', text: 'Auto-sync settings saved successfully!' }); + setTimeout(() => setMessage(null), 3000); + } else { + const errorData = await response.json(); + setMessage({ type: 'error', text: errorData.error ?? 'Failed to save auto-sync settings' }); + } + } catch (error) { + console.error('Error saving auto-sync settings:', error); + setMessage({ type: 'error', text: 'Failed to save auto-sync settings' }); + } finally { + setIsSaving(false); + } + }; + + const handleAppriseUrlsChange = (text: string) => { + setAppriseUrlsText(text); + const urls = text.split('\n').filter(url => url.trim() !== ''); + setAppriseUrls(urls); + }; + + const validateCronExpression = (cron: string) => { + if (!cron.trim()) { + setCronValidationError(''); + return true; + } + + // Basic cron validation - you might want to use a library like cron-validator + const cronRegex = /^(\*|([0-5]?\d)) (\*|([01]?\d|2[0-3])) (\*|([012]?\d|3[01])) (\*|([0]?\d|1[0-2])) (\*|([0-6]))$/; + const isValid = cronRegex.test(cron); + + if (!isValid) { + setCronValidationError('Invalid cron expression format'); + return false; + } + + setCronValidationError(''); + return true; + }; + + const handleCronChange = (cron: string) => { + setSyncIntervalCron(cron); + validateCronExpression(cron); + }; + + const testNotification = async () => { + try { + const response = await fetch('/api/settings/auto-sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ testNotification: true }) + }); + + if (response.ok) { + setMessage({ type: 'success', text: 'Test notification sent successfully!' }); + } else { + const errorData = await response.json(); + setMessage({ type: 'error', text: errorData.error ?? 'Failed to send test notification' }); + } + } catch (error) { + console.error('Error sending test notification:', error); + setMessage({ type: 'error', text: 'Failed to send test notification' }); + } + }; + + const triggerManualSync = async () => { + try { + const response = await fetch('/api/settings/auto-sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ triggerManualSync: true }) + }); + + if (response.ok) { + setMessage({ type: 'success', text: 'Manual sync triggered successfully!' }); + // Reload settings to get updated last sync time + await loadAutoSyncSettings(); + } else { + const errorData = await response.json(); + setMessage({ type: 'error', text: errorData.error ?? 'Failed to trigger manual sync' }); + } + } catch (error) { + console.error('Error triggering manual sync:', error); + setMessage({ type: 'error', text: 'Failed to trigger manual sync' }); + } + }; + if (!isOpen) return null; return ( @@ -340,6 +511,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr > Authentication + @@ -623,6 +806,237 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr )} + + {activeTab === 'auto-sync' && ( +
+
+

Auto-Sync Settings

+

+ Configure automatic synchronization of scripts with configurable intervals and notifications. +

+ + {/* Enable Auto-Sync */} +
+
+
+

Enable Auto-Sync

+

Automatically sync JSON files from GitHub at specified intervals

+
+ +
+
+ + {/* Sync Interval */} + {autoSyncEnabled && ( +
+

Sync Interval

+ +
+
+ + +
+ + {syncIntervalType === 'predefined' && ( +
+ +
+ )} + + {syncIntervalType === 'custom' && ( +
+ handleCronChange(e.target.value)} + className="w-full" + /> + {cronValidationError && ( +

{cronValidationError}

+ )} +

+ Format: minute hour day month weekday. See{' '} + + crontab.guru + {' '} + for examples +

+
+ )} +
+
+ )} + + {/* Auto-Download Options */} + {autoSyncEnabled && ( +
+

Auto-Download Options

+ +
+
+
+
Auto-download new scripts
+

Automatically download scripts that haven't been downloaded yet

+
+ +
+ +
+
+
Auto-update existing scripts
+

Automatically update scripts that have newer versions available

+
+ +
+
+
+ )} + + {/* Notifications */} + {autoSyncEnabled && ( +
+
+
+

Enable Notifications

+

Send notifications when sync completes

+

+ If you want any other notification service, please open an issue on the GitHub repository. +

+
+ +
+ + {notificationEnabled && ( +
+
+ +