05 - Script em nodejs para fazer sync
Dependências
No mesmo projeto do seu Next.js (ou em outro diretório Node):
npm install @supabase/supabase-js npm install -D typescript ts-node @types/node
Se ainda não tiver, inicialize o TS:
npx tsc --init
Variáveis de ambiente
No .env.local ou .env que você vai usar pro script:
Vide o link abaixo para entender das novas chaves do Supabase:
Supabase chave sb_secret que ignora RLS
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi... # service role
SUPABASE_BUCKET_DOCS=docs
OBSIDIAN_DOCS_PATH=C:\Users\voce\Obsidian\MeuVault\portal-docs
No Linux/WSL, seria algo como /home/voce/Obsidian/MeuVault/portal-docs.
Cliente admin do Supabase (reuso)
lib/supabaseAdmin.ts (se já existir, reaproveite):
// lib/supabaseAdmin.ts
import { createClient } from '@supabase/supabase-js';
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export const supabaseAdmin = createClient(url, serviceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
Script scripts/sync-obsidian.ts
Crie scripts/sync-obsidian.ts:
// scripts/sync-obsidian.ts
import 'dotenv/config';
import fs from 'node:fs/promises';
import path from 'node:path';
import { supabaseAdmin } from '../lib/supabaseAdmin';
const ROOT = process.env.OBSIDIAN_DOCS_PATH!;
const BUCKET = process.env.SUPABASE_BUCKET_DOCS || 'docs';
async function walk(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(fullPath)));
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
files.push(fullPath);
}
}
return files;
}
function getSlugAndStoragePath(fullPath: string): { slug: string; storagePath: string } {
const rel = path.relative(ROOT, fullPath).replace(/\\/g, '/'); // normaliza para /
const storagePath = rel; // ex: financeiro/relatorio-mensal.md
const slug = rel.replace(/\.md$/i, ''); // ex: financeiro/relatorio-mensal
return { slug, storagePath };
}
async function extractTitle(markdown: string, fallback: string): Promise<string> {
// Procura a primeira linha que comece com "# "
const lines = markdown.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('# ')) {
return trimmed.replace(/^#\s+/, '').trim();
}
}
// senão, usa o fallback (nome do arquivo sem extensão)
return fallback;
}
async function syncFile(fullPath: string) {
const { slug, storagePath } = getSlugAndStoragePath(fullPath);
const fileBuffer = await fs.readFile(fullPath);
const markdown = fileBuffer.toString('utf8');
const fileName = path.basename(storagePath, '.md');
const title = await extractTitle(markdown, fileName);
console.log(`➡️ Sync: ${slug} (${storagePath})`);
// 1) Upsert em app_docs
const { data: doc, error: docError } = await supabaseAdmin
.from('app_docs')
.upsert(
{
slug,
title,
bucket: BUCKET,
storage_path: storagePath,
},
{ onConflict: 'slug' }
)
.select()
.single();
if (docError) {
console.error(`Erro upsert app_docs (${slug}):`, docError.message);
return;
}
// 2) Upload/Upsert no Storage
const { error: storageError } = await supabaseAdmin.storage
.from(BUCKET)
.upload(storagePath, fileBuffer, {
upsert: true,
contentType: 'text/markdown; charset=utf-8',
});
if (storageError) {
console.error(`Erro upload Storage (${storagePath}):`, storageError.message);
return;
}
console.log(`✅ OK: ${slug}`);
}
async function main() {
console.log('🔍 Varre', ROOT);
const files = await walk(ROOT);
console.log(`Encontrados ${files.length} arquivos .md`);
for (const file of files) {
await syncFile(file);
}
console.log('✨ Sync concluído');
}
main().catch((err) => {
console.error('Erro fatal no sync:', err);
process.exit(1);
});
Script no package.json
{
"scripts": {
"sync:docs": "ts-node scripts/sync-obsidian.ts"
}
}
Rodar
pnpm run sync:docs
Isso vai:
- percorrer
OBSIDIAN_DOCS_PATH - para cada
.md:- upsert em
app_docs(slug, title, storage_path) - upload/atualização no bucket
docs
- upsert em
⚠️ Esse script não deleta docs do banco/storage se você apagar o arquivo local.
Se quiser remover também, dá pra estender: primeiro listarapp_docse comparar com os arquivos encontrados.
Acoplamento com o Obsidian
Temos 3 opções:
Opção 1 : Rodar manualmente
Sempre que fizermos alterações importantes nos docs:
npm run sync:docs
Simples e explícito.
Opção 2 : Integrar com Git + CI
Se sua Vault (ou a pasta portal-docs/) estiver versionada no GitHub:
- Quando fizer push pra
main, - Um GitHub Actions roda
npm run sync:docsem um runner, - O Actions usa
SUPABASE_SERVICE_ROLE_KEYcomo secret, - E sincroniza com o Supabase.
Fluxo:
- Você edita no Obsidian → commit → push
- CI roda → Supabase atualizado.
Opção 3 : Plugin de "Run shell command" no Obsidian
Existem plugins comunitários que permitem rodar comandos externos (ex: “Shell commands”, “Obsidian Git”, etc.).
Você poderia:
- Configurar um comando do plugin para chamar:
pnpm run sync:docs
- E ter um botão/menu dentro do Obsidian pra “Publicar/Sincronizar docs”.
Reforço de Segurança
Já que essa chave aparece no painel:
- Não publique no GitHub
- Adicione em:
GitHub → Repo → Settings → Secrets → SUPABASE_SERVICE_ROLE_KEY
- No Next.js, não deixe exposta no client.
Se quiser garantir, coloque isso nonext.config.mjs:
export default {
serverRuntimeConfig: {
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
},
publicRuntimeConfig: {}, // evita exposição
};
Mas como estamos usando server actions e o valor nunca vai para client, já está ok.
Exemplo completo
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default {
...nextConfig,
env: {
SUPABASE_SERVICE_ROLE_KEY: undefined, // impede vazamento no browser
},
serverRuntimeConfig: {
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
},
publicRuntimeConfig: {}
}