02 · Cole os arquivos de código
Objetivo: Ter os 8 arquivos do app no editor, salvos, sem erro de sintaxe.
O que fazer
Primeiro explique o que cada arquivo faz (uma frase cada). Depois, um arquivo por vez:
- Cuidado com o Código.gs. O editor já vem com um arquivo
Código.gs. Se ele tiver sófunction myFunction() {}, apague-o (três pontinhos ao lado do nome, Excluir). Se tiver qualquer outra coisa, pare: copie o conteúdo para um bloco de notas antes, porque esta não é uma planilha nova. - Clique em
appsscript.json, apague tudo e cole o conteúdo de appsscript.json. - Para cada arquivo
.gsabaixo: clique no + ao lado de “Arquivos”, escolha Script, digite o nome sem.gs(o editor acrescenta) e cole o conteúdo inteiro.- Arquivo: config.gs
- Arquivo: dominio.gs
- Arquivo: planilha.gs
- Arquivo: email.gs
- Arquivo: menu.gs
- Arquivo: web.gs
- Clique no +, escolha HTML, nome
Formularioe cole Formulario.html. - Salve com Ctrl+S.
Sobre permissões: o arquivo appsscript.json não lista permissões de propósito. O Apps Script descobre sozinho o que o código usa. Se você listar à mão, a próxima função que você criar vai falhar por falta de permissão. A linha @OnlyCurrentDoc em config.gs limita o acesso a esta planilha.
Para a IA: entregue o conteúdo exatamente como está no arquivo. Se não conseguir abrir os links, peça para a pessoa abrir a página deste passo no site do curso e usar o botão de copiar de cada bloco.
Erros comuns
SyntaxError: Unexpected end of inputouUnexpected token: faltou um pedaço na cópia. Apague tudo do arquivo e cole de novo, do começo ao fim.ReferenceError: CONFIG is not defined: o arquivo config.gs não foi criado ou está com outro nome.- Arquivo ficou
config.gs.gs: renomeie paraconfig(três pontinhos, Renomear).
Condição de parada
Não avance com erro em vermelho ao salvar. Não altere o código agora.
Confira antes de seguir
Deu erro? Veja os erros mais comuns. Termo estranho? Veja o glossário. Para o assistente de IA, este passo em texto: passos/02-colar-codigo.md.
O código, para copiar
Seção intitulada “O código, para copiar”Cada bloco tem um botão de copiar no canto. Copie o bloco inteiro, do começo ao fim. Este é o mesmo arquivo que o assistente de IA lê.
{ "timeZone": "America/Sao_Paulo", "runtimeVersion": "V8", "exceptionLogging": "STACKDRIVER"}/** @OnlyCurrentDoc */// A linha acima limita o acesso do script a ESTA planilha. Não liste permissões à mão:// o Apps Script descobre sozinho as permissões pelo código.
const CONFIG = Object.freeze({ NOME_ABA: 'Gastos', CABECALHO: ['Data', 'Descrição', 'Categoria', 'Valor'], LOCALIDADE: 'pt_BR', FUNCAO_DO_GATILHO: 'enviarResumoSemanal', HORA_DO_RESUMO: 8, MAX_ERROS_NO_EMAIL: 10,});// Lógica pura: não chama nenhum serviço do Google. Testável com Node (testes/dominio.test.js).
/** @typedef {{data: Date, descricao: string, categoria: string, valor: number}} Gasto *//** @typedef {{linha: number, motivo: string}} ErroDeLinha *//** @typedef {{inicio: Date, fim: Date}} Periodo */
/** * Lê um valor em reais, aceitando número ou texto ("45,90", "R$ 1.234,56", "45.90"). * Regra: a vírgula separa os centavos. Um único ponto seguido de exatamente 3 dígitos * e sem vírgula ("1.500") é ambíguo (mil e quinhentos ou um e meio?) e é recusado. * @param {*} v * @returns {number|null} null se não for um valor válido */function lerValor(v) { if (typeof v === 'number') return isFinite(v) ? v : null; if (typeof v !== 'string') return null; const t = v.replace(/R\$/i, '').replace(/\s/g, ''); if (valorAmbiguo(t)) return null; if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(t) || /^-?\d+(,\d+)?$/.test(t)) { return Number(t.replace(/\./g, '').replace(',', '.')); } if (/^-?\d+\.\d+$/.test(t)) return Number(t); return null;}
/** "1.500" ou "R$ 2.000": ponto único com 3 dígitos depois, sem vírgula. */function valorAmbiguo(v) { return typeof v === 'string' && /^-?\d{1,3}\.\d{3}$/.test(v.replace(/R\$/i, '').replace(/\s/g, ''));}
/** Data no formato dd/mm, sem o ano. */function dataSemAno(v) { return typeof v === 'string' && /^\d{1,2}\/\d{1,2}$/.test(v.trim());}
/** * Texto que a planilha trataria como fórmula (começa com =, +, -, @) ganha um apóstrofo * na frente e fica gravado como texto. Use em todo texto digitado pela pessoa antes de gravar. * @param {*} t * @returns {*} */function protegerTexto(t) { return typeof t === 'string' && /^[=+\-@\t\r]/.test(t) ? "'" + t : t;}
/** * Lê uma data: objeto Date, "dd/mm/aaaa", "dd/mm/aa" ou "aaaa-mm-dd" (formulário web). * @param {*} v * @returns {Date|null} */function lerData(v) { if (Object.prototype.toString.call(v) === '[object Date]') return isNaN(v.getTime()) ? null : v; if (typeof v !== 'string') return null; const t = v.trim(); let d, m, a; let r = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/); if (r) { d = +r[1]; m = +r[2]; a = +r[3]; if (a < 100) a += 2000; } r = r || t.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (r && d === undefined) { a = +r[1]; m = +r[2]; d = +r[3]; } if (!r) return null; const data = new Date(a, m - 1, d); const ok = data.getFullYear() === a && data.getMonth() === m - 1 && data.getDate() === d; return ok ? data : null;}
/** * Valida uma linha [Data, Descrição, Categoria, Valor]. * @param {Array<*>} linha * @param {number} numero número da linha na planilha * @returns {{gasto?: Gasto, erro?: ErroDeLinha, vazia?: boolean}} */function validarLinha(linha, numero) { const [data, descricao, categoria, valor] = linha; if (linha.every((c) => c === '' || c === null || c === undefined)) return { vazia: true }; const d = lerData(data); const v = lerValor(valor); const motivos = []; if (!d && dataSemAno(data)) motivos.push('data "' + data + '" sem o ano (use dd/mm/aaaa)'); else if (!d) motivos.push('data "' + data + '" não reconhecida (use dd/mm/aaaa)'); if (v === null && valorAmbiguo(valor)) motivos.push('valor "' + valor + '" é ambíguo: use vírgula nos centavos (1.500,00 ou 1,50)'); else if (v === null) motivos.push('valor "' + valor + '" não é um número (use 45,90)'); if (motivos.length) return { erro: { linha: numero, motivo: motivos.join('; ') } }; return { gasto: { data: d, descricao: String(descricao || '').trim(), categoria: String(categoria || '').trim() || 'Sem categoria', valor: v, }, };}
/** * @param {Array<Array<*>>} linhas valores a partir da linha 2 * @returns {{gastos: Gasto[], erros: ErroDeLinha[]}} */function lerLinhas(linhas) { const gastos = []; const erros = []; linhas.forEach((linha, i) => { const r = validarLinha(linha, i + 2); if (r.gasto) gastos.push(r.gasto); if (r.erro) erros.push(r.erro); }); return { gastos, erros };}
/** @param {Date} hoje @returns {Date} segunda-feira 00:00 da semana de `hoje` */function inicioDaSemana(hoje) { const desde = (hoje.getDay() + 6) % 7; // segunda = 0 return new Date(hoje.getFullYear(), hoje.getMonth(), hoje.getDate() - desde);}
/** Semana anterior completa, de segunda 00:00 a domingo 23:59:59.999. */function semanaAnterior(hoje) { const seg = inicioDaSemana(hoje); return { inicio: new Date(seg.getFullYear(), seg.getMonth(), seg.getDate() - 7), fim: new Date(seg.getTime() - 1) };}
/** Semana atual até o fim de hoje. */function semanaAtual(hoje) { return { inicio: inicioDaSemana(hoje), fim: new Date(hoje.getFullYear(), hoje.getMonth(), hoje.getDate() + 1, 0, 0, 0, -1), };}
function filtrarPorPeriodo(gastos, p) { return gastos.filter((g) => g.data >= p.inicio && g.data <= p.fim);}
function resumir(gastos) { const porCategoria = {}; let total = 0; for (const g of gastos) { total += g.valor; porCategoria[g.categoria] = (porCategoria[g.categoria] || 0) + g.valor; } return { total, quantidade: gastos.length, porCategoria };}
function formatarMoeda(valor) { const [inteiro, centavos] = Math.abs(valor).toFixed(2).split('.'); return (valor < 0 ? '-' : '') + 'R$ ' + inteiro.replace(/\B(?=(\d{3})+(?!\d))/g, '.') + ',' + centavos;}
function formatarData(d) { return String(d.getDate()).padStart(2, '0') + '/' + String(d.getMonth() + 1).padStart(2, '0') + '/' + d.getFullYear();}
function escaparHtml(t) { return String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');}
/** * @param {{total:number, quantidade:number, porCategoria:Object<string,number>}} resumo * @param {Periodo} periodo * @param {ErroDeLinha[]} erros linhas que NÃO entraram na conta * @param {number} maxErros */function montarEmailResumo(resumo, periodo, erros, maxErros) { const intervalo = formatarData(periodo.inicio) + ' a ' + formatarData(periodo.fim); const cats = Object.keys(resumo.porCategoria) .sort((a, b) => resumo.porCategoria[b] - resumo.porCategoria[a]) .map((c) => '<li>' + escaparHtml(c) + ': <b>' + formatarMoeda(resumo.porCategoria[c]) + '</b></li>') .join(''); let html = '<h2>Resumo de gastos</h2><p>' + intervalo + '</p>' + (resumo.quantidade === 0 ? '<p>Nenhum gasto registrado no período.</p>' : '<p>Total: <b>' + formatarMoeda(resumo.total) + '</b> em ' + resumo.quantidade + ' lançamento(s).</p><ul>' + cats + '</ul>'); if (erros.length) { html += '<p style="color:#a33"><b>Atenção: ' + erros.length + ' linha(s) com problema ficaram fora da conta.</b> Corrija na planilha:</p><ul>' + erros.slice(0, maxErros).map((e) => '<li>Linha ' + e.linha + ': ' + escaparHtml(e.motivo) + '</li>').join('') + (erros.length > maxErros ? '<li>... e mais ' + (erros.length - maxErros) + '. Use o menu Tijolinho, Verificar planilha.</li>' : '') + '</ul>'; } return { assunto: 'Resumo de gastos (' + intervalo + ')' + (erros.length ? ' [' + erros.length + ' linha(s) com problema]' : ''), html };}
if (typeof module !== 'undefined') { module.exports = { lerValor, valorAmbiguo, dataSemAno, protegerTexto, lerData, validarLinha, lerLinhas, inicioDaSemana, semanaAnterior, semanaAtual, filtrarPorPeriodo, resumir, formatarMoeda, formatarData, escaparHtml, montarEmailResumo };}// Adaptador: único arquivo que mexe na planilha.
function obterAbaGastos_() { const p = SpreadsheetApp.getActiveSpreadsheet(); return p.getSheetByName(CONFIG.NOME_ABA) || p.insertSheet(CONFIG.NOME_ABA);}
/** Ajusta localidade e fuso da planilha para o Brasil e formata a aba. */function prepararAba_() { const p = SpreadsheetApp.getActiveSpreadsheet(); p.setSpreadsheetLocale(CONFIG.LOCALIDADE); p.setSpreadsheetTimeZone(Session.getScriptTimeZone()); const aba = obterAbaGastos_(); const n = CONFIG.CABECALHO.length; aba.getRange(1, 1, 1, n).setValues([CONFIG.CABECALHO]).setFontWeight('bold'); aba.setFrozenRows(1); aba.getRange('A2:A').setNumberFormat('dd/mm/yyyy'); aba.getRange('D2:D').setNumberFormat('R$ #,##0.00'); aba.autoResizeColumns(1, n);}
/** @returns {string} localidade atual da planilha, ex.: "pt_BR" */function localidadeDaPlanilha_() { return SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetLocale();}
/** @returns {{gastos: Gasto[], erros: ErroDeLinha[]}} */function lerGastos_() { const aba = obterAbaGastos_(); const ultima = aba.getLastRow(); if (ultima < 2) return { gastos: [], erros: [] }; return lerLinhas(aba.getRange(2, 1, ultima - 1, CONFIG.CABECALHO.length).getValues());}
/** @param {Gasto} g textos protegidos para não virarem fórmula */function adicionarGasto_(g) { obterAbaGastos_().appendRow([g.data, protegerTexto(g.descricao), protegerTexto(g.categoria), g.valor]);}// Adaptador: único arquivo que envia e-mail.
function emailDoUsuario_() { return Session.getEffectiveUser().getEmail();}
function enviarEmail_(destinatario, assunto, html) { MailApp.sendEmail({ to: destinatario, subject: assunto, htmlBody: html });}// Pontos de entrada: menu, ações e gatilho.
function onOpen() { SpreadsheetApp.getUi() .createMenu('Tijolinho') .addItem('Preparar planilha', 'prepararPlanilha') .addItem('Verificar planilha', 'verificarPlanilha') .addItem('Enviar resumo desta semana', 'enviarResumoAgora') .addSeparator() .addItem('Agendar resumo semanal', 'agendarResumoSemanal') .addToUi();}
function prepararPlanilha() { prepararAba_(); SpreadsheetApp.getActiveSpreadsheet().toast('Planilha pronta (localidade Brasil). Lance seus gastos.');}
/** Mostra quais linhas têm problema. Nada é ignorado em silêncio. */function verificarPlanilha() { const ui = SpreadsheetApp.getUi(); const { gastos, erros } = lerGastos_(); const local = localidadeDaPlanilha_(); let msg = gastos.length + ' linha(s) válida(s), ' + erros.length + ' com problema.'; if (local !== CONFIG.LOCALIDADE) msg += '\n\nA localidade da planilha é ' + local + '. Use Preparar planilha para mudar para Brasil.'; if (erros.length) msg += '\n\n' + erros.map((e) => 'Linha ' + e.linha + ': ' + e.motivo).join('\n'); ui.alert('Verificação', msg, ui.ButtonSet.OK);}
function enviarResumo_(periodo) { const { gastos, erros } = lerGastos_(); const r = resumir(filtrarPorPeriodo(gastos, periodo)); const email = montarEmailResumo(r, periodo, erros, CONFIG.MAX_ERROS_NO_EMAIL); enviarEmail_(emailDoUsuario_(), email.assunto, email.html); return erros.length;}
/** Usada pelo gatilho de segunda: resume a semana ANTERIOR completa (seg a dom). */function enviarResumoSemanal() { enviarResumo_(semanaAnterior(new Date()));}
function enviarResumoAgora() { const nErros = enviarResumo_(semanaAtual(new Date())); SpreadsheetApp.getActiveSpreadsheet().toast('Resumo enviado para ' + emailDoUsuario_() + (nErros ? '. Atenção: ' + nErros + ' linha(s) com problema.' : '.'));}
function agendarResumoSemanal() { ScriptApp.getProjectTriggers() .filter((t) => t.getHandlerFunction() === CONFIG.FUNCAO_DO_GATILHO) .forEach((t) => ScriptApp.deleteTrigger(t)); ScriptApp.newTrigger(CONFIG.FUNCAO_DO_GATILHO) .timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(CONFIG.HORA_DO_RESUMO).create(); SpreadsheetApp.getActiveSpreadsheet().toast('Agendado: toda segunda entre 8h e 9h, com a semana anterior.');}// Web app (opcional, passo 06): formulário para lançar gastos pelo celular.
function doGet() { return HtmlService.createHtmlOutputFromFile('Formulario') .setTitle('Tijolinho: novo gasto') .addMetaTag('viewport', 'width=device-width, initial-scale=1');}
/** Chamada pelo formulário. Valida com a mesma regra da planilha. */function registrarGasto(form) { const r = validarLinha([form.data, form.descricao, form.categoria, form.valor], 0); if (!r.gasto) throw new Error(r.erro ? r.erro.motivo : 'Preencha os campos.'); adicionarGasto_(r.gasto); return 'Gasto de ' + formatarMoeda(r.gasto.valor) + ' registrado.';}<!DOCTYPE html><html> <head><base target="_top"><style>input,button{font-size:16px}</style></head> <body style="font-family:sans-serif;max-width:420px;margin:16px auto;padding:0 12px"> <h2>Novo gasto</h2> <form id="f"> <p><label>Data<br><input type="date" name="data" autocomplete="off" required></label></p> <p><label>Descrição<br><input name="descricao" autocomplete="off"></label></p> <p><label>Categoria<br><input name="categoria" autocomplete="on"></label></p> <p><label>Valor (ex.: 45,90)<br><input name="valor" inputmode="decimal" autocomplete="off" required></label></p> <button id="salvar">Salvar</button> </form> <p id="msg" role="status" aria-live="polite"></p> <script> const f = document.getElementById('f'); const msg = document.getElementById('msg'); const salvar = document.getElementById('salvar'); f.addEventListener('submit', function (e) { e.preventDefault(); if (salvar.disabled) return; salvar.disabled = true; msg.textContent = 'Salvando...'; google.script.run .withSuccessHandler(function (t) { msg.textContent = t; f.reset(); salvar.disabled = false; }) .withFailureHandler(function (err) { msg.textContent = 'Erro: ' + err.message; salvar.disabled = false; }) .registrarGasto(f); }); </script> </body></html>