feat: scaffold inicial del plugin de Obsidian osint_obsidian_plugin

Plugin fino (id osint-db) que habla HTTP con el service local osint_db
(FastAPI + DuckDB) y renderiza tablas de datos en las notas del vault
osint mediante el code block osintdb. Incluye parser puro de directivas
con tests (node --test), settings tab, comando de paleta, enlaces
internos para columnas note_path, build con esbuild + tsc y deploy.sh
al vault.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
agent
2026-06-12 23:58:04 +02:00
commit 011ccfb8cd
13 changed files with 1088 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
main.js
+120
View File
@@ -0,0 +1,120 @@
---
name: osint_obsidian_plugin
lang: ts
domain: osint
version: 0.1.0
description: "Plugin de Obsidian (id osint-db) para el vault osint: ejecuta queries SQL y queries nombradas contra el service local osint_db (FastAPI + DuckDB) via HTTP y renderiza tablas de datos dentro de las notas mediante el code block osintdb. Plugin fino: no embebe ninguna base de datos."
tags: [osint, obsidian, plugin, duckdb]
uses_functions: []
uses_types: []
framework: "obsidian-plugin"
entry_point: "main.ts"
dir_path: "projects/osint/apps/osint_obsidian_plugin"
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/osint_obsidian_plugin"
e2e_checks:
- id: build
cmd: "pnpm install --frozen-lockfile=false && pnpm build"
timeout_s: 300
- id: artifact
cmd: "test -f main.js"
- id: tests
cmd: "pnpm test"
timeout_s: 60
---
# osint_obsidian_plugin
Plugin de Obsidian para el vault `~/Obsidian/osint`. Es deliberadamente fino: no embebe
DuckDB ni ninguna otra base de datos. Toda la lógica de datos vive en el service local
`osint_db` (FastAPI, dueño de la DuckDB con datos OSINT), con el que el plugin habla por
HTTP usando `requestUrl` de la API de Obsidian (evita CORS; nunca `fetch`).
## Qué hace
1. **Code block `osintdb`**: dentro de cualquier nota, un bloque de código con lenguaje
`osintdb` se renderiza como una tabla de datos. Dos formas de contenido:
- SQL crudo (el bloque entero es un SELECT, va a `POST /api/query`).
- Directivas `clave: valor` al inicio del bloque: `query` (nombre de query guardada,
va a `POST /api/query/named`), `max_rows` y `title`.
2. **Render seguro**: tabla HTML construida con `createEl` (los valores se insertan como
texto, nunca como HTML crudo), header sticky, conteo de filas y nota "(truncado)"
cuando el service recorta el resultado. Errores del service y service caído se
muestran con mensajes claros, incluido el comando para arrancar `osint_db`.
3. **Botón "Refrescar"** por bloque: re-ejecuta la query sin recargar la nota.
4. **Enlaces a notas**: si una columna se llama `note_path` y el valor termina en `.md`,
la celda se renderiza como enlace interno de Obsidian que abre la nota al hacer clic.
5. **Settings tab**: URL base del service (default `http://127.0.0.1:8771`) y `max_rows`
por defecto (500), persistidos con `loadData`/`saveData`.
6. **Comando de paleta** "OSINT DB: Insertar bloque de query": inserta una plantilla de
bloque `osintdb` en el editor.
## Ejemplo de bloque
Forma con SQL crudo:
```osintdb
title: Personas del caso X
max_rows: 100
SELECT nombre, contexto, note_path FROM main.personas WHERE contexto = 'caso_x';
```
Forma con query nombrada (definida en el service):
```osintdb
query: personas_por_contexto
max_rows: 100
```
## Contrato API del service osint_db
Base URL por defecto: `http://127.0.0.1:8771`. El service responde siempre HTTP 200 y el
plugin decide por el campo `status` del body.
- `GET /api/health` → `{"status":"ok","db_path":"...","tables":N}`
- `GET /api/tables` → listado de tablas con schema, kind, row_count y columnas.
- `POST /api/query` body `{"sql":"SELECT ...","params":[],"max_rows":500}` →
`{"status":"ok","columns":[...],"rows":[...],"row_count":N,"truncated":bool}` o
`{"status":"error","error":"..."}`.
- `GET /api/queries` → queries nombradas disponibles.
- `POST /api/query/named` body `{"name":"...","max_rows":500}` → misma shape que `/api/query`.
## Cómo construir
```bash
cd projects/osint/apps/osint_obsidian_plugin
pnpm install
pnpm build # tsc --noEmit + esbuild -> main.js
pnpm test # tests del parser de bloques (node --test, sin Obsidian)
```
Requiere Node >= 23 para los tests (usa el type stripping nativo de `node --test` sobre
archivos `.ts`).
## Cómo desplegar
```bash
./deploy.sh
```
Copia `manifest.json`, `main.js` y `styles.css` a
`/home/enmanuel/Obsidian/osint/.obsidian/plugins/osint-db/`.
**Activación manual (la hace el humano, no un agente):** tras el deploy, abrir Obsidian →
Settings → Community plugins → activar "OSINT DB". Si Obsidian ya estaba abierto, hace
falta recargar la app (Ctrl+R) o reiniciarla para que detecte el plugin nuevo o la
versión nueva de `main.js`.
## Estructura
- `main.ts` — plugin completo (processor, render, settings, comando).
- `parse.ts` — parser puro del contenido de los bloques `osintdb` (testeable sin Obsidian).
- `tests/parse.test.ts` — tests del parser con `node --test`.
- `esbuild.config.mjs` — bundle CommonJS a `main.js` con `obsidian` como external.
- `deploy.sh` — copia los artefactos al vault.
## Gotchas
- El plugin no arranca el service: si `osint_db` no está corriendo, los bloques muestran
"osint_db no responde en <url>" con el comando de arranque como hint.
- `main.js` es artefacto de build (gitignored): siempre `pnpm build` antes de `deploy.sh`.
- `isDesktopOnly: true` en el manifest — el service es local, no tiene sentido en móvil.
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Despliega el plugin al vault osint: copia manifest.json, main.js y styles.css
# a la carpeta de plugins de Obsidian. Requiere haber corrido `pnpm build` antes.
set -euo pipefail
cd "$(dirname "$0")"
TARGET="/home/enmanuel/Obsidian/osint/.obsidian/plugins/osint-db"
if [ ! -f main.js ]; then
echo "ERROR: falta main.js — ejecuta 'pnpm build' primero." >&2
exit 1
fi
mkdir -p "$TARGET"
cp manifest.json main.js styles.css "$TARGET/"
echo "Plugin desplegado en $TARGET"
ls -l "$TARGET"
+28
View File
@@ -0,0 +1,28 @@
// Build del plugin: empaqueta main.ts en un main.js CommonJS que Obsidian carga
// directamente. El paquete "obsidian" y los módulos de Electron/Node quedan como
// externals porque los provee el propio runtime de Obsidian.
import esbuild from "esbuild";
import { builtinModules } from "node:module";
const banner = `/*
Bundle generado por esbuild. No editar a mano: el código fuente vive en main.ts.
*/`;
await esbuild.build({
entryPoints: ["main.ts"],
bundle: true,
outfile: "main.js",
format: "cjs",
target: "es2020",
platform: "browser",
sourcemap: false,
treeShaking: true,
logLevel: "info",
banner: { js: banner },
external: [
"obsidian",
"electron",
...builtinModules,
...builtinModules.map((m) => `node:${m}`),
],
});
+255
View File
@@ -0,0 +1,255 @@
// Plugin de Obsidian "OSINT DB". Plugin fino: no embebe ninguna base de datos,
// habla HTTP con el service local osint_db (FastAPI + DuckDB) y renderiza los
// resultados como tablas dentro de las notas mediante el code block `osintdb`.
import {
App,
Editor,
Plugin,
PluginSettingTab,
Setting,
requestUrl,
} from "obsidian";
import { parseBlock, ParsedBlock } from "./parse";
interface OsintDbSettings {
/** URL base del service osint_db. */
baseUrl: string;
/** Límite de filas por defecto cuando el bloque no declara max_rows. */
maxRows: number;
}
const DEFAULT_SETTINGS: OsintDbSettings = {
baseUrl: "http://127.0.0.1:8771",
maxRows: 500,
};
/** Respuesta de /api/query y /api/query/named. */
interface QueryResponse {
status: string;
error?: string;
columns?: string[];
rows?: Record<string, unknown>[];
row_count?: number;
truncated?: boolean;
}
const SNIPPET = [
"```osintdb",
"title: Mi consulta",
"max_rows: 100",
"SELECT * FROM main.personas LIMIT 20;",
"```",
"",
].join("\n");
export default class OsintDbPlugin extends Plugin {
settings: OsintDbSettings = DEFAULT_SETTINGS;
async onload(): Promise<void> {
await this.loadSettings();
this.addSettingTab(new OsintDbSettingTab(this.app, this));
this.registerMarkdownCodeBlockProcessor("osintdb", (source, el) => {
const block = parseBlock(source);
const container = el.createDiv({ cls: "osintdb-block" });
void this.renderBlock(container, block);
});
this.addCommand({
id: "insert-query-block",
name: "Insertar bloque de query",
editorCallback: (editor: Editor) => {
editor.replaceSelection(SNIPPET);
},
});
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
/** Ejecuta la query del bloque contra el service y pinta el resultado. */
private async renderBlock(container: HTMLElement, block: ParsedBlock): Promise<void> {
container.empty();
if (block.error) {
this.renderError(container, block.error);
return;
}
const header = container.createDiv({ cls: "osintdb-header" });
header.createEl("span", {
cls: "osintdb-title",
text: block.title ?? (block.query ? `query: ${block.query}` : "SQL"),
});
const refreshBtn = header.createEl("button", {
cls: "osintdb-refresh",
text: "Refrescar",
attr: { type: "button", "aria-label": "Re-ejecutar la query" },
});
refreshBtn.addEventListener("click", () => {
void this.renderBlock(container, block);
});
const body = container.createDiv({ cls: "osintdb-body" });
body.createEl("div", { cls: "osintdb-loading", text: "Consultando osint_db..." });
const maxRows = block.maxRows ?? this.settings.maxRows;
let data: QueryResponse;
try {
data = await this.runQuery(block, maxRows);
} catch (e) {
body.empty();
this.renderUnreachable(body);
return;
}
body.empty();
if (data.status !== "ok") {
this.renderError(body, data.error ?? "El service devolvió un error sin mensaje.");
return;
}
this.renderTable(body, data);
}
/** Llama a /api/query o /api/query/named según la forma del bloque. */
private async runQuery(block: ParsedBlock, maxRows: number): Promise<QueryResponse> {
const base = this.settings.baseUrl.replace(/\/+$/, "");
const isNamed = block.query !== undefined;
const url = isNamed ? `${base}/api/query/named` : `${base}/api/query`;
const payload = isNamed
? { name: block.query, max_rows: maxRows }
: { sql: block.sql, params: [], max_rows: maxRows };
// requestUrl (API de Obsidian) evita CORS; nunca usar fetch aquí.
const res = await requestUrl({
url,
method: "POST",
contentType: "application/json",
body: JSON.stringify(payload),
throw: false,
});
return res.json as QueryResponse;
}
private renderTable(parent: HTMLElement, data: QueryResponse): void {
const columns = data.columns ?? [];
const rows = data.rows ?? [];
if (columns.length === 0) {
parent.createEl("div", { cls: "osintdb-empty", text: "La query no devolvió columnas." });
return;
}
const wrapper = parent.createDiv({ cls: "osintdb-table-wrapper" });
const table = wrapper.createEl("table", { cls: "osintdb-table" });
const thead = table.createEl("thead");
const headRow = thead.createEl("tr");
for (const col of columns) {
headRow.createEl("th", { text: col });
}
const tbody = table.createEl("tbody");
for (const row of rows) {
const tr = tbody.createEl("tr");
for (const col of columns) {
const td = tr.createEl("td");
this.renderCell(td, col, row[col]);
}
}
const count = data.row_count ?? rows.length;
const suffix = data.truncated ? " (truncado)" : "";
parent.createEl("div", {
cls: "osintdb-footer",
text: `${count} fila${count === 1 ? "" : "s"}${suffix}`,
});
}
/**
* Pinta una celda. Los valores se insertan siempre como texto (createEl usa
* textContent por debajo), nunca como HTML. Las columnas note_path con valor
* terminado en .md se renderizan como enlace interno al estilo Obsidian.
*/
private renderCell(td: HTMLElement, column: string, value: unknown): void {
if (value === null || value === undefined) {
td.createEl("span", { cls: "osintdb-null", text: "∅" });
return;
}
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
if (column === "note_path" && text.endsWith(".md")) {
const link = td.createEl("a", { cls: "internal-link", text });
link.addEventListener("click", (ev) => {
ev.preventDefault();
void this.app.workspace.openLinkText(text.slice(0, -3), "");
});
return;
}
td.setText(text);
}
private renderError(parent: HTMLElement, message: string): void {
const box = parent.createDiv({ cls: "osintdb-error" });
box.createEl("strong", { text: "Error osint_db" });
box.createEl("div", { text: message });
}
private renderUnreachable(parent: HTMLElement): void {
const box = parent.createDiv({ cls: "osintdb-error" });
box.createEl("strong", { text: `osint_db no responde en ${this.settings.baseUrl}` });
box.createEl("div", {
text: "Arranca el service local (ver projects/osint/apps/osint_db/app.md), por ejemplo:",
});
box.createEl("code", {
text: "cd ~/fn_registry/projects/osint/apps/osint_db && .venv/bin/python -m uvicorn main:app --port 8771",
});
}
}
class OsintDbSettingTab extends PluginSettingTab {
plugin: OsintDbPlugin;
constructor(app: App, plugin: OsintDbPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("URL base del service osint_db")
.setDesc("Dirección HTTP donde escucha el service local (FastAPI + DuckDB).")
.addText((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.baseUrl)
.setValue(this.plugin.settings.baseUrl)
.onChange(async (value) => {
this.plugin.settings.baseUrl = value.trim() || DEFAULT_SETTINGS.baseUrl;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("max_rows por defecto")
.setDesc("Límite de filas cuando el bloque no declara la directiva max_rows.")
.addText((text) =>
text
.setPlaceholder(String(DEFAULT_SETTINGS.maxRows))
.setValue(String(this.plugin.settings.maxRows))
.onChange(async (value) => {
const n = Number(value);
if (Number.isInteger(n) && n > 0) {
this.plugin.settings.maxRows = n;
await this.plugin.saveSettings();
}
})
);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"id": "osint-db",
"name": "OSINT DB",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Ejecuta queries SQL contra el service local osint_db (DuckDB) y muestra tablas de datos dentro de las notas del vault.",
"author": "dataforge",
"isDesktopOnly": true
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "osint-db",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Plugin de Obsidian que habla con el service osint_db (FastAPI + DuckDB) para mostrar tablas de datos OSINT en las notas.",
"scripts": {
"build": "tsc --noEmit && node esbuild.config.mjs",
"typecheck": "tsc --noEmit",
"test": "node --test tests/parse.test.ts"
},
"devDependencies": {
"@types/node": "^22.10.0",
"esbuild": "^0.24.2",
"obsidian": "^1.7.2",
"typescript": "^5.7.2"
}
}
+80
View File
@@ -0,0 +1,80 @@
// Parseo del contenido de un bloque ```osintdb```. Módulo puro, sin dependencias
// de Obsidian, para poder testearlo con node --test sin levantar la app.
//
// Dos formas de bloque:
// 1. SQL crudo: el bloque entero es una sentencia SELECT que se manda a /api/query.
// 2. Con directivas: líneas "clave: valor" al inicio del bloque. Si hay directiva
// "query" se ejecuta una query nombrada via /api/query/named. Las directivas
// "max_rows" y "title" aplican a ambas formas.
export interface ParsedBlock {
/** Nombre de la query guardada en el service (forma named). */
query?: string;
/** SQL crudo a ejecutar (forma raw). */
sql?: string;
/** Límite de filas pedido al service. */
maxRows?: number;
/** Título a mostrar encima de la tabla. */
title?: string;
/** Mensaje de error de parseo; si está presente, el resto se ignora. */
error?: string;
}
const DIRECTIVE_RE = /^(query|max_rows|title)\s*:\s*(.*)$/;
/**
* Parsea el texto de un bloque osintdb. Consume las líneas de directivas del
* inicio (clave: valor) y trata el resto como SQL crudo. Reglas:
* - "query" presente -> query nombrada (no se admite SQL adicional en el bloque).
* - sin "query" pero con SQL -> query cruda.
* - bloque vacío o directivas inválidas -> error.
*/
export function parseBlock(source: string): ParsedBlock {
const lines = source.split(/\r?\n/);
const result: ParsedBlock = {};
let i = 0;
for (; i < lines.length; i++) {
const line = lines[i].trim();
if (line === "" && result.query === undefined && result.maxRows === undefined && result.title === undefined) {
// Líneas en blanco iniciales antes de cualquier directiva: se saltan.
continue;
}
const m = line.match(DIRECTIVE_RE);
if (!m) {
break; // Fin de las directivas; lo que queda es SQL.
}
const key = m[1];
const value = m[2].trim();
if (key === "query") {
if (value === "") {
return { error: "La directiva 'query' necesita un nombre de query guardada." };
}
result.query = value;
} else if (key === "max_rows") {
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
return { error: `Valor inválido para max_rows: "${value}" (debe ser un entero positivo).` };
}
result.maxRows = n;
} else if (key === "title") {
result.title = value;
}
}
const rest = lines.slice(i).join("\n").trim();
if (result.query !== undefined) {
if (rest !== "") {
return { error: "El bloque mezcla la directiva 'query' con SQL. Usa una de las dos formas." };
}
return result;
}
if (rest === "") {
return { error: "Bloque vacío: escribe un SELECT o una directiva 'query: <nombre>'." };
}
result.sql = rest;
return result;
}
+377
View File
@@ -0,0 +1,377 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^22.10.0
version: 22.19.21
esbuild:
specifier: ^0.24.2
version: 0.24.2
obsidian:
specifier: ^1.7.2
version: 1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6)
typescript:
specifier: ^5.7.2
version: 5.9.3
packages:
'@codemirror/state@6.5.0':
resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==}
'@codemirror/view@6.38.6':
resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==}
'@esbuild/aix-ppc64@0.24.2':
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.24.2':
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.24.2':
resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.24.2':
resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.24.2':
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.24.2':
resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.24.2':
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.24.2':
resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.24.2':
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.24.2':
resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.24.2':
resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.24.2':
resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.24.2':
resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.24.2':
resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.24.2':
resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.24.2':
resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.24.2':
resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.24.2':
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.24.2':
resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.24.2':
resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.24.2':
resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.24.2':
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.24.2':
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.24.2':
resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.24.2':
resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@types/codemirror@5.60.8':
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@22.19.21':
resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==}
'@types/tern@0.23.9':
resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
esbuild@0.24.2:
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
engines: {node: '>=18'}
hasBin: true
moment@2.29.4:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
obsidian@1.13.1:
resolution: {integrity: sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==}
peerDependencies:
'@codemirror/state': 6.5.0
'@codemirror/view': 6.38.6
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
snapshots:
'@codemirror/state@6.5.0':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/view@6.38.6':
dependencies:
'@codemirror/state': 6.5.0
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@esbuild/aix-ppc64@0.24.2':
optional: true
'@esbuild/android-arm64@0.24.2':
optional: true
'@esbuild/android-arm@0.24.2':
optional: true
'@esbuild/android-x64@0.24.2':
optional: true
'@esbuild/darwin-arm64@0.24.2':
optional: true
'@esbuild/darwin-x64@0.24.2':
optional: true
'@esbuild/freebsd-arm64@0.24.2':
optional: true
'@esbuild/freebsd-x64@0.24.2':
optional: true
'@esbuild/linux-arm64@0.24.2':
optional: true
'@esbuild/linux-arm@0.24.2':
optional: true
'@esbuild/linux-ia32@0.24.2':
optional: true
'@esbuild/linux-loong64@0.24.2':
optional: true
'@esbuild/linux-mips64el@0.24.2':
optional: true
'@esbuild/linux-ppc64@0.24.2':
optional: true
'@esbuild/linux-riscv64@0.24.2':
optional: true
'@esbuild/linux-s390x@0.24.2':
optional: true
'@esbuild/linux-x64@0.24.2':
optional: true
'@esbuild/netbsd-arm64@0.24.2':
optional: true
'@esbuild/netbsd-x64@0.24.2':
optional: true
'@esbuild/openbsd-arm64@0.24.2':
optional: true
'@esbuild/openbsd-x64@0.24.2':
optional: true
'@esbuild/sunos-x64@0.24.2':
optional: true
'@esbuild/win32-arm64@0.24.2':
optional: true
'@esbuild/win32-ia32@0.24.2':
optional: true
'@esbuild/win32-x64@0.24.2':
optional: true
'@marijn/find-cluster-break@1.0.2': {}
'@types/codemirror@5.60.8':
dependencies:
'@types/tern': 0.23.9
'@types/estree@1.0.9': {}
'@types/node@22.19.21':
dependencies:
undici-types: 6.21.0
'@types/tern@0.23.9':
dependencies:
'@types/estree': 1.0.9
crelt@1.0.6: {}
esbuild@0.24.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.24.2
'@esbuild/android-arm': 0.24.2
'@esbuild/android-arm64': 0.24.2
'@esbuild/android-x64': 0.24.2
'@esbuild/darwin-arm64': 0.24.2
'@esbuild/darwin-x64': 0.24.2
'@esbuild/freebsd-arm64': 0.24.2
'@esbuild/freebsd-x64': 0.24.2
'@esbuild/linux-arm': 0.24.2
'@esbuild/linux-arm64': 0.24.2
'@esbuild/linux-ia32': 0.24.2
'@esbuild/linux-loong64': 0.24.2
'@esbuild/linux-mips64el': 0.24.2
'@esbuild/linux-ppc64': 0.24.2
'@esbuild/linux-riscv64': 0.24.2
'@esbuild/linux-s390x': 0.24.2
'@esbuild/linux-x64': 0.24.2
'@esbuild/netbsd-arm64': 0.24.2
'@esbuild/netbsd-x64': 0.24.2
'@esbuild/openbsd-arm64': 0.24.2
'@esbuild/openbsd-x64': 0.24.2
'@esbuild/sunos-x64': 0.24.2
'@esbuild/win32-arm64': 0.24.2
'@esbuild/win32-ia32': 0.24.2
'@esbuild/win32-x64': 0.24.2
moment@2.29.4: {}
obsidian@1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6):
dependencies:
'@codemirror/state': 6.5.0
'@codemirror/view': 6.38.6
'@types/codemirror': 5.60.8
moment: 2.29.4
style-mod@4.1.3: {}
typescript@5.9.3: {}
undici-types@6.21.0: {}
w3c-keyname@2.2.8: {}
+3
View File
@@ -0,0 +1,3 @@
# Configuración de pnpm: permite el script de build de esbuild (descarga su binario).
allowBuilds:
esbuild: true
+96
View File
@@ -0,0 +1,96 @@
/* Estilos del plugin OSINT DB. Usa variables CSS de Obsidian para que la tabla
sea legible tanto en tema oscuro como claro. */
.osintdb-block {
margin: 0.5em 0 1em 0;
}
.osintdb-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5em;
margin-bottom: 0.35em;
}
.osintdb-title {
font-weight: 600;
color: var(--text-normal);
}
.osintdb-refresh {
font-size: var(--font-ui-smaller);
padding: 2px 10px;
cursor: pointer;
}
.osintdb-table-wrapper {
max-height: 420px;
overflow: auto;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
}
.osintdb-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-ui-small);
margin: 0;
}
.osintdb-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--background-secondary);
color: var(--text-normal);
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--background-modifier-border);
white-space: nowrap;
}
.osintdb-table tbody td {
padding: 4px 10px;
border-bottom: 1px solid var(--background-modifier-border);
color: var(--text-normal);
vertical-align: top;
}
.osintdb-table tbody tr:hover {
background: var(--background-modifier-hover);
}
.osintdb-null {
color: var(--text-faint);
}
.osintdb-footer {
margin-top: 0.3em;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.osintdb-error {
border: 1px solid var(--background-modifier-error);
border-left: 4px solid var(--text-error);
border-radius: var(--radius-s);
padding: 8px 12px;
color: var(--text-normal);
}
.osintdb-error strong {
color: var(--text-error);
}
.osintdb-error code {
display: block;
margin-top: 4px;
user-select: all;
}
.osintdb-loading,
.osintdb-empty {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
+65
View File
@@ -0,0 +1,65 @@
// Tests del parser de bloques osintdb. Se ejecutan con `node --test tests/`
// gracias al type stripping nativo de Node (>= 23), sin necesidad de Obsidian.
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseBlock } from "../parse.ts";
test("SQL crudo: el bloque entero es la sentencia", () => {
const r = parseBlock("SELECT * FROM main.personas LIMIT 10;");
assert.equal(r.sql, "SELECT * FROM main.personas LIMIT 10;");
assert.equal(r.query, undefined);
assert.equal(r.error, undefined);
});
test("SQL multilínea se conserva entero", () => {
const src = "SELECT nombre, contexto\nFROM main.personas\nWHERE contexto = 'caso_x'";
const r = parseBlock(src);
assert.equal(r.sql, src);
});
test("query nombrada con max_rows", () => {
const r = parseBlock("query: personas_por_contexto\nmax_rows: 100");
assert.equal(r.query, "personas_por_contexto");
assert.equal(r.maxRows, 100);
assert.equal(r.sql, undefined);
assert.equal(r.error, undefined);
});
test("directivas title y max_rows seguidas de SQL crudo", () => {
const r = parseBlock("title: Personas\nmax_rows: 50\nSELECT * FROM main.personas");
assert.equal(r.title, "Personas");
assert.equal(r.maxRows, 50);
assert.equal(r.sql, "SELECT * FROM main.personas");
});
test("líneas en blanco iniciales se ignoran", () => {
const r = parseBlock("\n\nquery: dominios_activos");
assert.equal(r.query, "dominios_activos");
});
test("bloque vacío devuelve error", () => {
const r = parseBlock(" \n ");
assert.ok(r.error);
});
test("max_rows no numérico devuelve error", () => {
const r = parseBlock("max_rows: muchas\nSELECT 1");
assert.ok(r.error?.includes("max_rows"));
});
test("query sin nombre devuelve error", () => {
const r = parseBlock("query: ");
assert.ok(r.error);
});
test("mezclar query nombrada con SQL devuelve error", () => {
const r = parseBlock("query: personas\nSELECT 1");
assert.ok(r.error?.includes("mezcla"));
});
test("un SELECT que contiene dos puntos no se confunde con directiva", () => {
const src = "SELECT 'query: falsa' AS texto FROM main.personas";
const r = parseBlock(src);
assert.equal(r.sql, src);
assert.equal(r.query, undefined);
});
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["main.ts", "parse.ts", "tests/**/*.ts"]
}