{
  "name": "dynamiccronjobs",
  "author": "Limon",
  "supportedGames": [
    "all"
  ],
  "takaroVersion": "main",
  "versions": [
    {
      "tag": "0.0.1",
      "description": "# Dynamic CronJob Manager: Automated Game Server Command Scheduler\n\n---\n🎥 **SETUP TUTORIAL**  \n**[► Watch the Complete Guide](https://youtu.be/OwxbBxYFZk8?si=7Fu3XVB972in6IvS)**  \n*Learn how to set up and configure dynamic cronjobs*\n---\n\nCreate and manage dynamic cronjobs for executing game server commands based on flexible schedules. Define commands and their cron expressions to automate tasks.  \n\n  -   **Dynamic Updates:** The module's `cronJobGenerator` cronjob automatically checks for and adapts to changes in the user configuration. This ensures that any modifications to commands or schedules are applied dynamically.\n  -   **Cron Syntax Validation:** The module validates the syntax of cron expressions. If an invalid expression is provided, that specific cronjob will be skipped, preventing errors and ensuring only valid schedules are applied.  For assistance with cron syntax, you can use a tool like [crontab.guru](https://crontab.guru/).\n  -   **Command Sequencing:** You can define multiple commands within a single cronjob entry, separated by semicolons. These commands will be executed sequentially according to the defined schedule.\n  -   **Game Server Specificity:** It is crucial to install the latest version of this module and create a **separate copy for each game server**. This ensures that cronjobs are executed on the intended server and avoids unintended cross-server execution.\n  -   **Automated Management:** The module handles the creation, updating, and cleanup of cronjobs. It removes any cronjobs that are no longer present in the configuration, maintaining a clean and accurate schedule.\n  -   **Internal Synchronization:** To ensure proper synchronization of cron schedules, the module may perform internal operations to update its configuration. This process is automated and does not require manual intervention from the user.",
      "configSchema": "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"cronjobs\":{\"type\":\"array\",\"title\":\"Cronjobs\",\"description\":\"List of cronjobs and their schedules\",\"items\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"description\":\"Optional name for the cronjob\",\"minLength\":1},\"command\":{\"type\":\"string\",\"description\":\"You can enter multiple commands separated by semicolons (;) to create a sequence of scheduled tasks for the same cron expression. \",\"minLength\":1},\"temporalValue\":{\"type\":\"string\",\"description\":\"Cron expression for execution schedule\",\"minLength\":1}},\"required\":[\"command\",\"temporalValue\"]}}},\"additionalProperties\":false}",
      "uiSchema": "{}",
      "commands": [],
      "hooks": [],
      "cronJobs": [
        {
          "name": "cronJobGenerator",
          "temporalValue": "*/15 * * * *",
          "description": "",
          "function": "import { takaro, data } from '@takaro/helpers';\n\nasync function main() {\n  const { gameServerId, module: mod } = data;\n  const cronjobs = mod.userConfig.cronjobs || [];\n\n  console.log('🔄 Dynamic Cronjob Generator starting...');\n\n  // State management using variables\n  const configHashKey = 'config_hash';\n  const lastProcessedKey = 'last_processed';\n  const backupConfigKey = 'backup_config';\n\n  try {\n    // Create configuration hash for change detection\n    const currentConfigHash = JSON.stringify(cronjobs).split('').reduce((a, b) => {\n      a = ((a << 5) - a) + b.charCodeAt(0);\n      return a & a;\n    }, 0).toString();\n\n    // Get stored state\n    let storedHash = null;\n    let lastProcessed = null;\n    try {\n      const hashVar = await takaro.variable.variableControllerSearch({\n        filters: {\n          key: [configHashKey],\n          moduleId: [mod.moduleId],\n          gameServerId: [gameServerId]\n        }\n      });\n      if (hashVar.data.data.length > 0) {\n        storedHash = hashVar.data.data[0].value;\n      }\n\n      const processedVar = await takaro.variable.variableControllerSearch({\n        filters: {\n          key: [lastProcessedKey],\n          moduleId: [mod.moduleId],\n          gameServerId: [gameServerId]\n        }\n      });\n      if (processedVar.data.data.length > 0) {\n        lastProcessed = new Date(processedVar.data.data[0].value);\n      }\n    } catch (error) {\n      console.log('📝 No previous state found, treating as first run');\n    }\n\n    // Check if configuration changed\n    if (storedHash === currentConfigHash && lastProcessed && (Date.now() - lastProcessed.getTime()) < 300000) { // 5 minutes\n      console.log('✅ No configuration changes detected, skipping update');\n      return;\n    }\n\n    // Backup current configuration\n    try {\n      await takaro.variable.variableControllerCreate({\n        key: backupConfigKey,\n        value: JSON.stringify({\n          userConfig: mod.userConfig,\n          timestamp: new Date().toISOString(),\n          hash: currentConfigHash\n        }),\n        gameServerId,\n        moduleId: mod.moduleId\n      });\n      console.log('💾 Configuration backup created');\n    } catch (backupError) {\n      // Update existing backup\n      const existingBackup = await takaro.variable.variableControllerSearch({\n        filters: {\n          key: [backupConfigKey],\n          moduleId: [mod.moduleId],\n          gameServerId: [gameServerId]\n        }\n      });\n      if (existingBackup.data.data.length > 0) {\n        await takaro.variable.variableControllerUpdate(existingBackup.data.data[0].id, {\n          value: JSON.stringify({\n            userConfig: mod.userConfig,\n            timestamp: new Date().toISOString(),\n            hash: currentConfigHash\n          })\n        });\n        console.log('💾 Configuration backup updated');\n      }\n    }\n\n    // Validate cronjobs first\n    const validatedCronjobs = [];\n    const invalidJobs = [];\n\n    function isValidCronSyntax(cronExpression) {\n      try {\n        cronExpression = cronExpression.trim();\n\n        // Check for special expressions\n        if (/^@(yearly|annually|monthly|weekly|daily|hourly|reboot)$/.test(cronExpression)) {\n          return true;\n        }\n\n        const parts = cronExpression.split(/\\s+/);\n        if (parts.length !== 5) {\n          return false;\n        }\n\n        // Valid characters per field\n        const validPatterns = [\n          /^[0-9,\\-*\\/]+$/, // Minutes\n          /^[0-9,\\-*\\/]+$/, // Hours  \n          /^[0-9,\\-*\\/?]+$/, // Day of month\n          /^[0-9,\\-*\\/]+$/, // Month\n          /^[0-9,\\-*\\/?a-zA-Z]+$/ // Day of week\n        ];\n\n        for (let i = 0; i < 5; i++) {\n          if (!validPatterns[i].test(parts[i])) {\n            return false;\n          }\n        }\n\n        // Range validation\n        const minutes = parts[0].split(/[,\\-\\/]/).filter(m => m !== '*' && /^\\d+$/.test(m));\n        if (minutes.some(m => parseInt(m) > 59)) return false;\n\n        const hours = parts[1].split(/[,\\-\\/]/).filter(h => h !== '*' && /^\\d+$/.test(h));\n        if (hours.some(h => parseInt(h) > 23)) return false;\n\n        const dom = parts[2].split(/[,\\-\\/]/).filter(d => d !== '*' && d !== '?' && /^\\d+$/.test(d));\n        if (dom.some(d => parseInt(d) < 1 || parseInt(d) > 31)) return false;\n\n        const months = parts[3].split(/[,\\-\\/]/).filter(m => m !== '*' && /^\\d+$/.test(m));\n        if (months.some(m => parseInt(m) < 1 || parseInt(m) > 12)) return false;\n\n        return true;\n      } catch (err) {\n        return false;\n      }\n    }\n\n    // Validate all cronjobs\n    for (let i = 0; i < cronjobs.length; i++) {\n      const job = cronjobs[i];\n      if (!isValidCronSyntax(job.temporalValue)) {\n        console.log(`❌ Invalid cron syntax in job ${job.name || i + 1}: \"${job.temporalValue}\"`);\n        invalidJobs.push(job);\n        continue;\n      }\n      validatedCronjobs.push(job);\n    }\n\n    if (invalidJobs.length > 0) {\n      console.log(`⚠️  Skipped ${invalidJobs.length} invalid cronjob(s)`);\n    }\n\n    // Get existing cronjobs for this module\n    const existingCronjobs = (await takaro.cronjob.cronJobControllerSearch({\n      filters: {\n        versionId: [mod.versionId]\n      }\n    })).data.data;\n\n    const processedJobs = new Set();\n    let jobCounter = 1;\n    let successCount = 0;\n    let errorCount = 0;\n\n    // Process validated cronjobs with individual CRUD operations\n    for (let i = 0; i < validatedCronjobs.length; i++) {\n      const job = validatedCronjobs[i];\n      const commands = job.command.split(';').map(cmd => cmd.trim()).filter(cmd => cmd);\n\n      for (let cmdIndex = 0; cmdIndex < commands.length; cmdIndex++) {\n        const command = commands[cmdIndex];\n        const seqSuffix = commands.length > 1 ? `-seq${cmdIndex + 1}` : '';\n        const jobNameSuffix = job.name ? `-${job.name}${seqSuffix}` : seqSuffix;\n        const jobName = `cr-${jobCounter}${jobNameSuffix}`;\n        jobCounter++;\n\n        processedJobs.add(jobName);\n\n        const jobFunction = `\nimport { takaro, data } from '@takaro/helpers';\nasync function main() {\n  const { gameServerId } = data;\n  await takaro.gameserver.gameServerControllerExecuteCommand(gameServerId, {\n    command: \\`${command}\\`,\n  });\n}\nawait main();\n        `.trim();\n\n        try {\n          const existingJob = existingCronjobs.find(j => j.name === jobName);\n\n          if (existingJob) {\n            // Only update if temporal value changed\n            if (existingJob.temporalValue !== job.temporalValue) {\n              console.log(`📝 Updating cronjob ${jobName}: ${existingJob.temporalValue} → ${job.temporalValue}`);\n              await takaro.cronjob.cronJobControllerUpdate(existingJob.id, {\n                name: jobName,\n                temporalValue: job.temporalValue,\n                function: jobFunction\n              });\n              successCount++;\n            }\n          } else {\n            console.log(`➕ Creating new cronjob: ${jobName}`);\n            try {\n              await takaro.cronjob.cronJobControllerCreate({\n                name: jobName,\n                temporalValue: job.temporalValue,\n                versionId: mod.versionId,\n                function: jobFunction\n              });\n              successCount++;\n            } catch (createError) {\n              if (createError.response?.status === 409) {\n                // Handle conflict by searching and updating\n                const conflictJobSearch = await takaro.cronjob.cronJobControllerSearch({\n                  filters: { name: [jobName] }\n                });\n\n                if (conflictJobSearch.data.data.length > 0) {\n                  const conflictJob = conflictJobSearch.data.data[0];\n                  await takaro.cronjob.cronJobControllerUpdate(conflictJob.id, {\n                    name: jobName,\n                    temporalValue: job.temporalValue,\n                    function: jobFunction\n                  });\n                  successCount++;\n                  console.log(`📝 Resolved conflict and updated: ${jobName}`);\n                } else {\n                  throw createError;\n                }\n              } else {\n                throw createError;\n              }\n            }\n          }\n        } catch (error) {\n          console.log(`❌ Error with cronjob \"${jobName}\": ${error.message || \"Unknown error\"}`);\n          errorCount++;\n        }\n      }\n    }\n\n    // Clean up orphaned cronjobs\n    let cleanupCount = 0;\n    for (const existingJob of existingCronjobs) {\n      if (/^cr-\\d+(-.*)?$/.test(existingJob.name) && !processedJobs.has(existingJob.name)) {\n        try {\n          await takaro.cronjob.cronJobControllerRemove(existingJob.id);\n          cleanupCount++;\n          console.log(`🗑️  Removed orphaned cronjob: ${existingJob.name}`);\n        } catch (error) {\n          console.log(`❌ Error removing cronjob \"${existingJob.name}\": ${error.message}`);\n          errorCount++;\n        }\n      }\n    }\n\n    // Update state variables\n    try {\n      // Update config hash\n      const hashVarSearch = await takaro.variable.variableControllerSearch({\n        filters: {\n          key: [configHashKey],\n          moduleId: [mod.moduleId],\n          gameServerId: [gameServerId]\n        }\n      });\n\n      if (hashVarSearch.data.data.length > 0) {\n        await takaro.variable.variableControllerUpdate(hashVarSearch.data.data[0].id, {\n          value: currentConfigHash\n        });\n      } else {\n        await takaro.variable.variableControllerCreate({\n          key: configHashKey,\n          value: currentConfigHash,\n          gameServerId,\n          moduleId: mod.moduleId\n        });\n      }\n\n      // Update last processed timestamp\n      const processedVarSearch = await takaro.variable.variableControllerSearch({\n        filters: {\n          key: [lastProcessedKey],\n          moduleId: [mod.moduleId],\n          gameServerId: [gameServerId]\n        }\n      });\n\n      if (processedVarSearch.data.data.length > 0) {\n        await takaro.variable.variableControllerUpdate(processedVarSearch.data.data[0].id, {\n          value: new Date().toISOString()\n        });\n      } else {\n        await takaro.variable.variableControllerCreate({\n          key: lastProcessedKey,\n          value: new Date().toISOString(),\n          gameServerId,\n          moduleId: mod.moduleId\n        });\n      }\n\n      console.log('📊 State variables updated successfully');\n    } catch (stateError) {\n      console.log(`⚠️  Warning: Could not update state variables: ${stateError.message}`);\n    }\n\n    console.log(`✅ Dynamic Cronjob Generator completed:`);\n    console.log(`   📈 ${successCount} operations successful`);\n    console.log(`   🗑️  ${cleanupCount} orphaned jobs cleaned`);\n    if (errorCount > 0) {\n      console.log(`   ❌ ${errorCount} errors encountered`);\n    }\n\n  } catch (error) {\n    console.log(`💥 Critical error in Dynamic Cronjob Generator: ${error.message}`);\n    \n    // Attempt to restore from backup if available\n    try {\n      const backupSearch = await takaro.variable.variableControllerSearch({\n        filters: {\n          key: [backupConfigKey],\n          moduleId: [mod.moduleId],\n          gameServerId: [gameServerId]\n        }\n      });\n\n      if (backupSearch.data.data.length > 0) {\n        const backup = JSON.parse(backupSearch.data.data[0].value);\n        console.log(`🔄 Backup available from ${backup.timestamp}`);\n        console.log('   Manual restoration may be required if issues persist');\n      }\n    } catch (backupError) {\n      console.log('⚠️  No backup available for restoration');\n    }\n\n    throw error; // Re-throw to ensure error is logged in events\n  }\n}\n\nawait main();"
        }
      ],
      "functions": [],
      "permissions": []
    }
  ]
}