Pular para o conteúdo

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:

  1. 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.
  2. Clique em appsscript.json, apague tudo e cole o conteúdo de appsscript.json.
  3. Para cada arquivo .gs abaixo: clique no + ao lado de “Arquivos”, escolha Script, digite o nome sem .gs (o editor acrescenta) e cole o conteúdo inteiro.
  4. Clique no +, escolha HTML, nome Formulario e cole Formulario.html.
  5. 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 input ou Unexpected 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 para config (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.

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ê.

appsscript.json
{
"timeZone": "America/Sao_Paulo",
"runtimeVersion": "V8",
"exceptionLogging": "STACKDRIVER"
}
config.gs
/** @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,
});
dominio.gs
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* @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 };
}
planilha.gs
// 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]);
}
email.gs
// 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 });
}
menu.gs
// 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.gs
// 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.';
}
Formulario.html
<!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>