// ───────────────────────────────────────────────────────────── // Semana Programada · script da planilha — OH Otimização Humana // Cópia servida pelo app. A fonte é apps_script/SemanaProgramada.gs; // editar ESTE arquivo não conserta nada — a próxima geração o reescreve. // Cole o conteúdo inteiro no Code.gs da SUA planilha (Extensões → Apps Script). // ───────────────────────────────────────────────────────────── /** * ============================================================ * SEMANA PROGRAMADA — ponte com a planilha do cliente * Ferramenta autoral de Carlos Canhisares · OH Otimização Humana * ============================================================ * * Este script mora DENTRO da planilha do cliente e é publicado como Web App. * A autorização acontece uma vez, entre o cliente e o Google — a nossa * aplicação nunca pede acesso à conta dele. * * Como instalar (no navegador, na própria planilha): * 1. Extensões → Apps Script * 2. Apagar o conteúdo do Code.gs e colar este arquivo inteiro * 3. Trocar o valor de TOKEN abaixo por um segredo qualquer, longo * 4. Implantar → Nova implantação → tipo "App da Web" * Executar como: Eu (o dono da planilha) * Quem tem acesso: Qualquer pessoa * 5. Autorizar quando o Google pedir * 6. Copiar a URL que termina em /exec * * A URL /exec + o TOKEN juntos dão acesso de leitura e escrita a esta * planilha. Tratar como senha: não colar em e-mail, não versionar em * repositório. No nosso lado eles ficam guardados no servidor, nunca no * navegador de ninguém. * * Contrato (o servidor da OH é quem chama; nunca o navegador do cliente, * porque o Apps Script não responde a preflight de CORS): * GET ?acao=ler&token=… → {ok, versao, atualizadoEm, doc} * POST ?acao=gravar&token=… → {ok, versao} | {ok:false, erro:'conflito', …} * corpo: {"doc": {...}, "versao": N, "force": false} * Content-Type: text/plain (requisição simples, sem preflight) */ var TOKEN = 'TROQUE-ESTE-VALOR-POR-UM-SEGREDO-LONGO'; var ABA_DADOS = '_dados'; // fidelidade total: o JSON do documento, fatiado var ABA_HIST = '_historico'; // versões substituídas — nada é descartado var HIST_MAX = 8; /* Uma célula do Sheets aguenta ~50.000 caracteres. Seis anos de Semana Programada passam disso com folga, então o JSON é gravado em fatias, uma por linha, e relido em ordem. */ var FATIA = 45000; var DIAS = ['SEGUNDA','TERÇA','QUARTA','QUINTA','SEXTA','SÁBADO','DOMINGO']; var MESES_C = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']; var SLOTS = 40; // 04:00 → 24:00, de 30 em 30 minutos var HORA_INICIO = 4; // ---------------------------------------------------------------- utilidades function resposta_(obj) { return ContentService.createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); } function autorizado_(e) { var t = (e && e.parameter && e.parameter.token) || ''; return t && t === TOKEN; } function planilha_() { return SpreadsheetApp.getActiveSpreadsheet(); } function aba_(nome) { var ss = planilha_(); return ss.getSheetByName(nome) || ss.insertSheet(nome); } function rotuloMes_(m) { return MESES_C[m.mes] + ' ' + String(m.ano).slice(2); } function hhmm_(slot) { var min = HORA_INICIO * 60 + slot * 30; var h = Math.floor(min / 60), mm = min % 60; return (h < 10 ? '0' : '') + h + ':' + (mm < 10 ? '0' : '') + mm; } // ------------------------------------------------------------- ler / gravar function lerRegistro_() { var sh = planilha_().getSheetByName(ABA_DADOS); if (!sh) return { versao: 0, atualizadoEm: null, doc: null }; var versao = Number(sh.getRange('B1').getValue() || 0); var quando = sh.getRange('B2').getValue(); var ultima = sh.getLastRow(); if (ultima < 4) return { versao: versao, atualizadoEm: quando || null, doc: null }; var fatias = sh.getRange(4, 1, ultima - 3, 1).getValues(); var json = ''; for (var i = 0; i < fatias.length; i++) json += String(fatias[i][0] || ''); if (!json) return { versao: versao, atualizadoEm: quando || null, doc: null }; var doc = null; try { doc = JSON.parse(json); } catch (err) { doc = null; } return { versao: versao, atualizadoEm: quando ? (quando instanceof Date ? quando.toISOString() : String(quando)) : null, doc: doc }; } function gravarRegistro_(doc, versao) { var sh = aba_(ABA_DADOS); sh.clear(); var agora = new Date().toISOString(); sh.getRange('A1').setValue('SEMANA PROGRAMADA — dados estruturados (não editar à mão)'); sh.getRange('B1').setValue(versao); sh.getRange('A2').setValue('atualizado em'); sh.getRange('B2').setValue(agora); sh.getRange('A3').setValue('json (fatiado a partir da linha 4)'); var json = JSON.stringify(doc); var linhas = []; for (var i = 0; i < json.length; i += FATIA) linhas.push([json.substr(i, FATIA)]); if (linhas.length) sh.getRange(4, 1, linhas.length, 1).setValues(linhas); sh.hideSheet(); return agora; } function arquivarNoHistorico_(registro) { if (!registro || !registro.doc) return; var sh = aba_(ABA_HIST); var json = JSON.stringify(registro.doc); // o histórico guarda o carimbo e o JSON; se passar do limite da célula, // guarda o começo e avisa — é histórico, não fonte de verdade sh.insertRowBefore(1); sh.getRange(1, 1, 1, 4).setValues([[ registro.versao, registro.atualizadoEm || '', json.length, json.length > 49000 ? json.substring(0, 49000) + '…(truncado)' : json ]]); var ultima = sh.getLastRow(); if (ultima > HIST_MAX) sh.deleteRows(HIST_MAX + 1, ultima - HIST_MAX); sh.hideSheet(); } /* As abas mensais são a representação LEGÍVEL para quem edita na planilha. O layout é o mesmo que o nosso parser lê (esquema 2022+ do arquivo real): cabeçalho na linha 6, objetivos na coluna B, horas na C, dias em D..J. */ /* Quantas abas mensais legíveis o script mantém. A aba `_dados` guarda o documento INTEIRO — nada se perde aqui; o limite é só da representação legível. Sem ele, quem importou seis anos de histórico faria o script reescrever ~72 abas a cada gravação, estourando o teto de 6 minutos do Apps Script e enchendo a planilha de abas que ninguém pediu. */ var ABAS_MENSAIS_MAX = 12; function escreverAbasMensais_(doc) { var ss = planilha_(); var todos = (doc && doc.meses) || []; // os mais recentes primeiro: são esses que alguém quer olhar e editar var meses = todos.slice(Math.max(0, todos.length - ABAS_MENSAIS_MAX)); for (var k = 0; k < meses.length; k++) { var m = meses[k]; var nome = rotuloMes_(m) + (m.titulo ? ' ' + String(m.titulo).substring(0, 20) : ''); nome = nome.substring(0, 31); var sh = ss.getSheetByName(nome); if (!sh) sh = ss.insertSheet(nome); sh.clear(); /* Frases acima do cabeçalho (é de lá que o parser as recolhe). Formato: `1- texto␣␣— Autor · Obra`. Os DOIS espaços antes do travessão são a marca que o leitor procura para separar a autoria — sem eles, uma frase que já contenha travessão perderia metade do texto. A obra vai depois do ` · ` e não de uma vírgula porque obra tem vírgula ("Poor Richard's Almanack, 1746") e o corte ficaria ambíguo. */ var frases = m.frases || []; for (var f = 0; f < Math.min(frases.length, 4); f++) { var atrib = ''; if (frases[f].a) atrib = ' — ' + frases[f].a + (frases[f].f ? ' · ' + frases[f].f : ''); sh.getRange(f + 1, 2).setValue((f + 1) + '- ' + frases[f].t + atrib); } sh.getRange(5, 4).setValue( (m.titulo ? String(m.titulo).toUpperCase() + ': ' : '') + 'TRANSFORMANDO OBJETIVOS EM TEMPO DE DEDICAÇÃO'); var cab = ['', 'OBJETIVOS/COMPROMISSOS', 'HORA'].concat(DIAS); sh.getRange(6, 1, 1, cab.length).setValues([cab]); // coluna de objetivos, agrupada como na planilha original var grupos = { atual: 'Compromissos atuais:', tres: 'Objetivos para 3 meses:', ano: 'Objetivos para 1 ano:' }; var listaB = []; ['atual', 'tres', 'ano'].forEach(function (g) { listaB.push(grupos[g]); (m.objetivos || []).filter(function (o) { return o.grupo === g; }) .forEach(function (o) { /* Os selos dos Pilares da Disposição ficam ao lado do objetivo, no mesmo padrão do [meta 8h]: o selo mora no objetivo, e a grade acha as colunas a partir do 'SEG' — coluna nova à esquerda deslocaria hora e objetivos. O principal vem primeiro, e é por essa posição que ele volta a ser reconhecido na leitura. */ var sel = (o.pilarPrincipal ? [o.pilarPrincipal] : []).concat( (o.pilares || []).filter(function (id) { return id !== o.pilarPrincipal; })); listaB.push(o.nome + (o.meta > 0 ? ' [meta ' + o.meta + 'h]' : '') + (sel.length ? ' [' + sel.join(',') + ']' : '')); }); listaB.push(''); }); var porId = {}; (m.objetivos || []).forEach(function (o) { porId[o.id] = o; }); var corpo = []; for (var s = 0; s < SLOTS; s++) { var linha = ['', listaB[s] || '', hhmm_(s)]; for (var d = 0; d < 7; d++) { var c = (m.celulas && m.celulas[d] && m.celulas[d][s]) || null; var o = c ? porId[c.obj] : null; linha.push(c ? (c.rot || (o ? o.nome : '')) : ''); } corpo.push(linha); } sh.getRange(7, 1, corpo.length, corpo[0].length).setValues(corpo); /* Resultados marcantes do mês, na coluna L — à direita dos sete dias, com a coluna K vazia de respiro, como na planilha original. Fica fora do bloco da grade de propósito: o leitor do outro lado acha os dias pelo 'SEG' e conta para trás, então nada que se escreva à direita atrapalha. */ var res = m.resultados || []; sh.getRange(6, 12).setValue('RESULTADOS MARCANTES DO MÊS').setFontWeight('bold'); var linhasRes = []; for (var q = 0; q < 24; q++) linhasRes.push([res[q] != null ? res[q] : '']); sh.getRange(7, 12, linhasRes.length, 1).setValues(linhasRes); sh.setColumnWidth(12, 260); sh.setColumnWidth(2, 240); sh.getRange(6, 1, 1, cab.length).setFontWeight('bold'); } } // ------------------------------------------------------------------ handlers /* Devolve as abas mensais CRUAS, para o parser do outro lado interpretar. De propósito não há parser aqui: ele já existe, já foi rodado contra o arquivo real de 133 abas, e uma segunda implementação divergiria da primeira. `abas` é uma lista separada por | com os nomes pedidos; sem ela, devolve todas as que não são a `_dados` nem o `_historico`, limitadas a 12 para o corpo da resposta não estourar. */ function comecaComAlgum_(nome, prefixos) { for (var i = 0; i < prefixos.length; i++) { if (nome.indexOf(prefixos[i]) === 0) return true; } return false; } function lerAbasMensais_(e) { var pedidas = String((e.parameter && e.parameter.abas) || '').split('|').filter(String); var ss = planilha_(); var saida = []; var todas = ss.getSheets(); for (var i = 0; i < todas.length && saida.length < 12; i++) { var sh = todas[i]; var nome = sh.getName(); if (nome === ABA_DADOS || nome === ABA_HIST) continue; // Casa por PREFIXO: a aba pode ter título ("Jul 26" mais um nome) e quem pede manda // só "Jul 26". Comparar por igualdade fazia toda aba com título ser ignorada // — e o botão respondia "nada de novo" sem nada estar errado à vista. if (pedidas.length && !comecaComAlgum_(nome, pedidas)) continue; var ultima = sh.getLastRow(), colunas = sh.getLastColumn(); if (ultima < 2 || colunas < 2) continue; // getDisplayValues: queremos o texto como a pessoa vê, igual ao que o // parser recebe quando lê um .xlsx saida.push({ nome: nome, aoa: sh.getRange(1, 1, ultima, colunas).getDisplayValues() }); } return saida; } function doGet(e) { if (!autorizado_(e)) return resposta_({ ok: false, erro: 'nao_autorizado' }); var acao = (e.parameter && e.parameter.acao) || 'ler'; if (acao === 'ler-abas') { var lock2 = LockService.getDocumentLock(); lock2.waitLock(20000); try { return resposta_({ ok: true, abas: lerAbasMensais_(e) }); } finally { lock2.releaseLock(); } } if (acao !== 'ler') return resposta_({ ok: false, erro: 'acao_desconhecida' }); var lock = LockService.getDocumentLock(); lock.waitLock(20000); try { var r = lerRegistro_(); return resposta_({ ok: true, versao: r.versao, atualizadoEm: r.atualizadoEm, doc: r.doc }); } finally { lock.releaseLock(); } } function doPost(e) { if (!autorizado_(e)) return resposta_({ ok: false, erro: 'nao_autorizado' }); var corpo; try { corpo = JSON.parse((e.postData && e.postData.contents) || '{}'); } catch (err) { return resposta_({ ok: false, erro: 'corpo_invalido' }); } if (!corpo.doc || !corpo.doc.meses) return resposta_({ ok: false, erro: 'doc_invalido' }); var lock = LockService.getDocumentLock(); lock.waitLock(30000); try { var atual = lerRegistro_(); var enviada = Number(corpo.versao || 0); // Mesma regra dos dois lados: versão divergente não grava em silêncio. if (enviada !== atual.versao && !corpo.force) { return resposta_({ ok: false, erro: 'conflito', versao: atual.versao, atualizadoEm: atual.atualizadoEm, doc: atual.doc }); } arquivarNoHistorico_(atual); var nova = atual.versao + 1; var quando = gravarRegistro_(corpo.doc, nova); escreverAbasMensais_(corpo.doc); return resposta_({ ok: true, versao: nova, atualizadoEm: quando }); } finally { lock.releaseLock(); } } /* ============================================================ MANUTENÇÃO — rode À MÃO no editor, nunca pelo Web App. Apaga TODAS as abas geradas (mensais, _dados e _historico) e devolve a planilha ao estado de nova. Não toca no aplicativo: o documento de lá continua intacto, e a próxima sincronia recria o que fizer sentido. Uma planilha precisa ter ao menos uma aba, então sobra uma em branco. ============================================================ */ function limparTudoAqui() { var ss = planilha_(); var todas = ss.getSheets(); var sobra = ss.insertSheet('Início'); var apagadas = 0; for (var i = 0; i < todas.length; i++) { if (todas[i].getSheetId() === sobra.getSheetId()) continue; ss.deleteSheet(todas[i]); apagadas++; } Logger.log('Abas apagadas: ' + apagadas + '. Sobrou apenas "Início".'); return apagadas; } /* Rode esta função uma vez, na mão, dentro do editor do Apps Script: ela dispara o pedido de autorização e confirma que o script vê a planilha. Deve devolver o nome do arquivo no log. */ function testarAqui() { Logger.log('Planilha: ' + planilha_().getName()); Logger.log('Registro atual: ' + JSON.stringify(lerRegistro_()).substring(0, 300)); }