motor 1.x). * @return array|null linha da tabela */ public static function getActive($operation) { global $DB; if (!$DB->tableExists('glpi_plugin_redmine_templates')) { return null; } $it = $DB->request([ 'FROM' => 'glpi_plugin_redmine_templates', 'WHERE' => ['operation' => $operation, 'is_active' => 1] ]); return count($it) > 0 ? $it->current() : null; } /** * Linha do template da operação (ativa ou não), ou null. */ public static function getRow($operation) { global $DB; if (!$DB->tableExists('glpi_plugin_redmine_templates')) { return null; } $it = $DB->request(['FROM' => 'glpi_plugin_redmine_templates', 'WHERE' => ['operation' => $operation]]); return count($it) > 0 ? $it->current() : null; } /** * Grava (upsert) o template de uma operação. */ public static function save($operation, $templateJson, $active = false) { global $DB; $row = [ 'template' => $templateJson, 'is_active' => $active ? 1 : 0, ]; $it = $DB->request(['FROM' => 'glpi_plugin_redmine_templates', 'WHERE' => ['operation' => $operation]]); if (count($it) > 0) { $DB->update('glpi_plugin_redmine_templates', $row, ['operation' => $operation]); } else { $DB->insert('glpi_plugin_redmine_templates', $row + ['operation' => $operation]); } } // ------------------------------------------------------------------ // Seeds (comportamento 1.x expresso como template) // ------------------------------------------------------------------ /** * Template padrão da operação (seed do botão "Gerar template"). * @return array estrutura do payload (antes do json_encode) */ public static function defaultTemplate($operation) { switch ($operation) { case 'create_issue': return ['issue' => [ 'project_id' => '{{ vinculo.projeto_redmine }}', 'subject' => '{{ chamado.titulo | truncar:255 }}', 'description' => '{{ chamado.descricao | sem_html }}', 'tracker_id' => '{{ config.tracker_padrao }}', 'priority_id' => '{{ config.prioridade_padrao }}', 'status_id' => '{{ map.status[chamado.status] }}', 'category_id' => '{{ vinculo.categoria_padrao }}', 'watcher_user_ids' => '{{ config.observadores }}', ]]; case 'log_time': return ['time_entry' => [ 'issue_id' => '{{ vinculo.issue_redmine }}', 'hours' => '{{ tarefa.horas }}', 'comments' => '{{ tarefa.comentario }}', 'activity_id' => '{{ config.atividade_padrao }}', ]]; case 'update_status': return ['issue' => [ 'status_id' => '{{ map.status[chamado.status] }}', ]]; case 'create_project': return ['project' => [ 'name' => '{{ form.nome }}', 'identifier' => '{{ form.identificador | identificador }}', 'description' => '{{ form.descricao }}', 'is_public' => '{{ form.publico }}', 'parent_id' => '{{ form.subprojeto_de }}', 'tracker_ids' => '{{ form.trackers }}', 'enabled_module_names' => '{{ form.modulos }}', ]]; } return []; } /** * Enriquece um seed com os custom fields OBRIGATÓRIOS descobertos no cache * de metadados (por tipo de objeto Redmine da operação). */ public static function mergeDiscoveredCustomFields($operation, array $template) { $type_by_op = [ 'create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry', ]; if (!isset($type_by_op[$operation])) { return $template; } global $DB; $it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]); $config = count($it) > 0 ? $it->current() : []; $metadata = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : []; $root = array_key_first($template); foreach (($metadata['project_custom_fields'] ?? []) as $f) { // metadata_cache hoje guarda só os de projeto; issue/time_entry na 2.1 if (($f['customized_type'] ?? 'project') !== $type_by_op[$operation]) { continue; } if (!empty($f['is_required'])) { $template[$root]['custom_field_values'][(string) $f['id']] = ($operation === 'create_project') ? '{{ form.cf_' . (int) $f['id'] . ' }}' : ''; } } return $template; } // ------------------------------------------------------------------ // Paleta (catálogo de variáveis para a UI) // ------------------------------------------------------------------ /** * Catálogo de variáveis disponíveis, com a tag pronta para copiar. * @return array de ['group','tag','desc'] */ public static function variableCatalog() { $cat = []; $add = function ($group, $var, $desc) use (&$cat) { $cat[] = ['group' => $group, 'tag' => '{{ ' . $var . ' }}', 'desc' => $desc]; }; $add('chamado', 'chamado.id', 'ID do chamado'); $add('chamado', 'chamado.titulo | truncar:255', 'Título (truncado p/ o limite do Redmine)'); $add('chamado', 'chamado.descricao | sem_html', 'Descrição sem HTML'); $add('chamado', 'chamado.status', 'Status do chamado (número GLPI)'); $add('chamado', 'chamado.entidade', 'ID da entidade do chamado'); $add('tarefa', 'tarefa.horas', 'Duração em horas decimais (1h30 = 1.5)'); $add('tarefa', 'tarefa.segundos', 'Duração em segundos'); $add('tarefa', 'tarefa.descricao', 'Descrição da tarefa (sem HTML)'); $add('tarefa', 'tarefa.comentario', 'Descrição ou fallback ("Tempo lançado via GLPI...")'); $add('tarefa', 'tarefa.data', 'Data da tarefa (AAAA-MM-DD)'); $add('vinculo', 'vinculo.projeto_redmine', 'ID do projeto Redmine vinculado'); $add('vinculo', 'vinculo.issue_redmine', 'ID da issue Redmine do chamado'); $add('vinculo', 'vinculo.categoria_padrao', 'Categoria padrão do vínculo'); $add('config', 'config.tracker_padrao', 'Tracker padrão (formconfig)'); $add('config', 'config.prioridade_padrao', 'Prioridade padrão (formconfig)'); $add('config', 'config.atividade_padrao', 'Atividade padrão (formconfig)'); $add('config', 'config.observadores', 'Observadores padrão (array de ids)'); $add('map', 'map.status[chamado.status]', 'Status Redmine mapeado do status do chamado'); $add('chamado', 'chamado.tecnico', 'Nome do técnico atribuído (texto; a AUTORIA real é automática via impersonation)'); $add('tarefa', 'tarefa.autor', 'Nome de quem lançou a tarefa (texto; a AUTORIA real é automática via impersonation)'); $add('form (criar projeto)', 'form.nome', 'Nome digitado no form'); $add('form (criar projeto)', 'form.identificador | identificador', 'Identificador normalizado'); $add('form (criar projeto)', 'form.descricao', 'Descrição do form'); $add('form (criar projeto)', 'form.publico', 'Público (true/false)'); $add('form (criar projeto)', 'form.subprojeto_de', 'Projeto pai escolhido'); $add('form (criar projeto)', 'form.trackers', 'Trackers selecionados (array)'); $add('form (criar projeto)', 'form.modulos', 'Módulos selecionados (array)'); $add('form (criar projeto)', 'form.cf_', 'Custom field do form (ex.: form.cf_4)'); return $cat; } /** * Dicionário "PARA" da operação: os campos que o Redmine espera/aceita, * com obrigatoriedade e valores possíveis do ambiente (via metadata cache). * Alimenta a tabela "Campos que o Redmine espera" de cada card do editor. * @return array de ['path','desc','required','values'=>[]] */ public static function fieldDictionary($operation) { global $DB; $it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]); $config = count($it) > 0 ? $it->current() : []; $meta = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : []; $vals = function ($list) { $out = []; foreach (($list ?? []) as $e) { if (isset($e['id'], $e['name'])) { $out[] = $e['id'] . ' = ' . $e['name']; } } return $out; }; $trackers = $vals($meta['trackers'] ?? []); $statuses = $vals($meta['statuses'] ?? []); $priorities = $vals($meta['priorities'] ?? []); $activities = $vals($meta['activities'] ?? []); $D = []; $add = function ($path, $desc, $req = false, $values = []) use (&$D) { $D[] = ['path' => $path, 'desc' => $desc, 'required' => $req, 'values' => $values]; }; switch ($operation) { case 'create_issue': $add('issue.subject', 'Título da tarefa (máx. 255)', true); $add('issue.description', 'Descrição'); $add('issue.tracker_id', 'Tipo de tarefa', false, $trackers); $add('issue.status_id', 'Situação', false, $statuses); $add('issue.priority_id', 'Prioridade', false, $priorities); $add('issue.category_id', 'Categoria (os IDs variam por projeto Redmine)'); $add('issue.assigned_to_id', 'Atribuído para (ID de usuário Redmine)'); $add('issue.fixed_version_id', 'Versão/marco (por projeto)'); $add('issue.parent_issue_id', 'Tarefa pai (subtarefa)'); $add('issue.estimated_hours', 'Horas estimadas (decimal)'); $add('issue.start_date', 'Data de início (AAAA-MM-DD)'); $add('issue.due_date', 'Data limite (AAAA-MM-DD)'); $add('issue.is_private', 'Privada (true/false)'); $add('issue.watcher_user_ids', 'Observadores (array de IDs)'); break; case 'log_time': $add('time_entry.hours', 'Horas (decimal — use {{ tarefa.horas }})', true); $add('time_entry.comments', 'Comentário (obrigatório em alguns ambientes)'); $add('time_entry.activity_id', 'Atividade (habilitada no projeto de destino!)', false, $activities); $add('time_entry.spent_on', 'Data do lançamento (AAAA-MM-DD)'); break; case 'update_status': $add('issue.status_id', 'Situação', false, $statuses); $add('issue.notes', 'Comentário adicionado à issue na atualização'); $add('issue.assigned_to_id', 'Atribuído para (ID de usuário Redmine)'); $add('issue.done_ratio', '% concluído (0-100)'); break; case 'create_project': $add('project.name', 'Nome do projeto', true); $add('project.identifier', 'Identificador (minúsculas — use o filtro | identificador)', true); $add('project.description', 'Descrição'); $add('project.is_public', 'Público (true/false)'); $add('project.parent_id', 'ID do projeto pai (subprojeto de)'); $add('project.inherit_members', 'Herdar membros do pai (true/false)'); $add('project.tracker_ids', 'Tipos de tarefa habilitados (array)', false, $trackers); $add('project.enabled_module_names', 'Módulos (array: issue_tracking, time_tracking, wiki...)'); break; } // Custom fields do ambiente, por tipo de objeto da operação $type_by_op = ['create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry']; $root_by_op = ['create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry']; if (isset($type_by_op[$operation])) { $cfs = $meta['custom_fields'] ?? $meta['project_custom_fields'] ?? []; foreach ($cfs as $f) { if (($f['customized_type'] ?? '') !== $type_by_op[$operation]) { continue; } $pv = array_column($f['possible_values'] ?? [], 'value'); $add( $root_by_op[$operation] . '.custom_field_values.' . (int) $f['id'], $f['name'] . ' (campo personalizado do seu Redmine)', !empty($f['is_required']), $pv ); } } return $D; } // ------------------------------------------------------------------ // Contexto // ------------------------------------------------------------------ /** * Contexto para o create_project (aba Redmine de Projeto/Entidade): * base (config/map) + namespace form.* a partir do POST do formulário. */ public static function buildFormContext(array $post) { $ctx = self::buildContext(); $form = [ 'nome' => (string) ($post['name'] ?? ''), 'identificador' => (string) ($post['identifier'] ?? ''), 'descricao' => (string) ($post['description'] ?? ''), 'publico' => !empty($post['is_public']), 'subprojeto_de' => !empty($post['parent_id']) ? (int) $post['parent_id'] : null, 'trackers' => !empty($post['tracker_ids']) && is_array($post['tracker_ids']) ? array_values(array_filter(array_map('intval', $post['tracker_ids']))) : null, 'modulos' => !empty($post['enabled_module_names']) && is_array($post['enabled_module_names']) ? array_values($post['enabled_module_names']) : null, ]; foreach (($post['custom_field_values'] ?? []) as $cfid => $v) { if ($v !== '' && $v !== null && $v !== '0' && $v !== 0) { $form['cf_' . (int) $cfid] = $v; } } $ctx['form'] = $form; return $ctx; } /** * Monta o contexto de variáveis (a "paleta") para o render. */ public static function buildContext(?Ticket $ticket = null, ?TicketTask $task = null, ?array $linkRow = null, $redmineProjectId = null, $issueId = null) { $cfg = PluginRedmineConfig::getConfigRow(); $statusMap = !empty($cfg['status_map']) ? (json_decode($cfg['status_map'], true) ?: []) : []; $ctx = [ 'config' => [ 'tracker_padrao' => ((int) ($cfg['default_tracker_id'] ?? 0)) ?: null, 'prioridade_padrao' => ((int) ($cfg['default_priority_id'] ?? 0)) ?: null, 'atividade_padrao' => ((int) ($cfg['default_activity_id'] ?? 0)) ?: null, 'observadores' => PluginRedmineConfig::getDefaultWatcherIds() ?: null, ], 'map' => [ // ids como int para o JSON final sair numérico 'status' => array_map('intval', $statusMap), ], 'vinculo' => [ 'projeto_redmine' => $redmineProjectId ? (int) $redmineProjectId : null, 'issue_redmine' => $issueId ? (int) $issueId : null, 'categoria_padrao' => ($linkRow && !empty($linkRow['default_category_id'])) ? (int) $linkRow['default_category_id'] : null, ], ]; if ($ticket !== null) { global $DB; $tecnico = null; $itT = $DB->request([ 'SELECT' => 'users_id', 'FROM' => 'glpi_tickets_users', 'WHERE' => ['tickets_id' => (int) $ticket->getID(), 'type' => \CommonITILActor::ASSIGN, ['users_id' => ['>', 0]]], 'LIMIT' => 1, ]); if (count($itT) > 0) { $tecnico = getUserName($itT->current()['users_id']); } $ctx['chamado'] = [ 'id' => (int) $ticket->getID(), 'titulo' => (string) $ticket->fields['name'], 'descricao' => (string) $ticket->fields['content'], 'status' => (int) $ticket->fields['status'], 'entidade' => (int) $ticket->fields['entities_id'], 'tecnico' => $tecnico, ]; } if ($task !== null) { $desc = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? '')))); $ctx['tarefa'] = [ 'id' => (int) $task->getID(), 'segundos' => (int) $task->fields['actiontime'], 'horas' => round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2), 'descricao' => $desc, // fallback do comentário obrigatório é responsabilidade do MOTOR 'comentario' => ($desc !== '') ? $desc : 'Tempo lançado via GLPI (chamado #' . (int) $task->fields['tickets_id'] . ')', 'data' => substr((string) ($task->fields['date'] ?? ''), 0, 10) ?: null, 'autor' => !empty($task->fields['users_id']) ? getUserName($task->fields['users_id']) : null, ]; } return $ctx; } /** * Contexto de EXEMPLO para o testador: config/mapa reais + chamado/tarefa * fictícios + custom fields do form preenchidos com o 1º valor possível. */ public static function sampleContext($operation) { global $DB; $ctx = self::buildContext(); $link = $DB->request(['FROM' => 'glpi_plugin_redmine_projects', 'LIMIT' => 1]); $rpid = count($link) > 0 ? (int) $link->current()['redmine_project_id'] : 1; $ctx['vinculo']['projeto_redmine'] = $rpid; $ctx['vinculo']['issue_redmine'] = 12345; $ctx['chamado'] = [ 'id' => 4321, 'titulo' => 'Exemplo: erro no servidor de e-mail', 'descricao' => '

Descrição de exemplo do chamado.

', 'status' => 2, 'entidade' => 0, 'tecnico' => 'Fulano Técnico (exemplo)', ]; $ctx['tarefa'] = [ 'id' => 987, 'segundos' => 5400, 'horas' => 1.5, 'descricao' => 'Tarefa de exemplo concluída', 'comentario' => 'Tarefa de exemplo concluída', 'data' => date('Y-m-d'), 'autor' => 'Fulano Técnico (exemplo)', ]; $form = [ 'nome' => 'Projeto Exemplo', 'identificador' => 'Projeto-Exemplo', 'descricao' => 'Descrição de exemplo', 'publico' => false, 'subprojeto_de' => null, 'trackers' => !empty($ctx['config']['tracker_padrao']) ? [$ctx['config']['tracker_padrao']] : null, 'modulos' => ['issue_tracking', 'time_tracking'], ]; // custom fields obrigatórios do form: usa o 1º valor possível (teste passável) $it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]); $config = count($it) > 0 ? $it->current() : []; $meta = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : []; foreach (($meta['project_custom_fields'] ?? []) as $f) { $pv = array_column($f['possible_values'] ?? [], 'value'); $form['cf_' . (int) $f['id']] = $pv[0] ?? 'exemplo'; } $ctx['form'] = $form; return $ctx; } // ------------------------------------------------------------------ // Render // ------------------------------------------------------------------ /** * Renderiza um template JSON com o contexto. Lança RuntimeException em * template inválido (o chamador cai no motor 1.x). * @return array payload pronto (nulls e arrays vazios removidos) */ public static function render($templateJson, array $ctx) { $tpl = json_decode((string) $templateJson, true); if (!is_array($tpl)) { throw new \RuntimeException('template não é JSON válido'); } $out = self::walk($tpl, $ctx); return self::prune($out); } private static function walk($node, array $ctx) { if (is_array($node)) { $out = []; foreach ($node as $k => $v) { $out[$k] = self::walk($v, $ctx); } return $out; } if (is_string($node)) { return self::renderString($node, $ctx); } return $node; } private static function renderString($s, array $ctx) { // Tag ocupando o valor inteiro => tipo nativo da variável if (preg_match('/^\{\{\s*(.+?)\s*\}\}$/s', trim($s), $m) && trim($s) === $s) { return self::evalExpr($m[1], $ctx); } // Tags embutidas => interpolação de string return preg_replace_callback('/\{\{\s*(.+?)\s*\}\}/s', function ($m) use ($ctx) { $v = self::evalExpr($m[1], $ctx); if (is_array($v)) { return json_encode($v, JSON_UNESCAPED_UNICODE); } if (is_bool($v)) { return $v ? 'true' : 'false'; } return (string) ($v ?? ''); }, $s); } private static function evalExpr($expr, array $ctx) { // pipeline: var | filtro:arg | filtro2 ('|' entre aspas simples é preservado) $parts = preg_split("/\|(?=(?:[^']*'[^']*')*[^']*$)/", $expr); $value = self::resolveVar(trim(array_shift($parts)), $ctx); foreach ($parts as $f) { $value = self::applyFilter($value, trim($f)); } return $value; } private static function resolveVar($path, array $ctx) { // lookup indexado: map.status[chamado.status] if (preg_match('/^([a-z0-9_.]+)\[([a-z0-9_.]+)\]$/i', $path, $m)) { $map = self::dig($ctx, $m[1]); $key = self::dig($ctx, $m[2]); if (!is_array($map) || $key === null) { return null; } return $map[$key] ?? $map[(string) $key] ?? null; } return self::dig($ctx, $path); } private static function dig(array $ctx, $path) { $cur = $ctx; foreach (explode('.', $path) as $p) { if (is_array($cur) && array_key_exists($p, $cur)) { $cur = $cur[$p]; } else { return null; } } return $cur; } private static function applyFilter($v, $filter) { $name = $filter; $arg = null; if (strpos($filter, ':') !== false) { [$name, $arg] = explode(':', $filter, 2); $name = trim($name); $arg = trim($arg); if (preg_match("/^'(.*)'$/s", $arg, $m)) { $arg = $m[1]; } } switch ($name) { case 'truncar': return mb_substr((string) $v, 0, max(1, (int) $arg)); case 'sem_html': return trim(strip_tags(html_entity_decode((string) $v))); case 'minusculo': return strtolower((string) $v); case 'identificador': return strtolower(preg_replace('/[^a-zA-Z0-9\-_]/', '-', (string) $v)); case 'padrao': return ($v === null || $v === '' || $v === []) ? $arg : $v; default: // filtro desconhecido: no-op (não derruba o render) return $v; } } /** * Remove nulls e arrays vazios (campos não definidos não vão no payload, * igual ao motor 1.x). Strings vazias são preservadas. */ private static function prune($node) { if (!is_array($node)) { return $node; } $out = []; foreach ($node as $k => $v) { $v = self::prune($v); if ($v === null || (is_array($v) && $v === [])) { continue; } $out[$k] = $v; } return $out; } }