commit 011ccfb8cdd8fa56b1f6c5541791b4469c0a8c63 Author: agent Date: Fri Jun 12 23:58:04 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deb381b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +main.js diff --git a/app.md b/app.md new file mode 100644 index 0000000..90f08f0 --- /dev/null +++ b/app.md @@ -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 " 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. diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..6ff3079 --- /dev/null +++ b/deploy.sh @@ -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" diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..a65f8e5 --- /dev/null +++ b/esbuild.config.mjs @@ -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}`), + ], +}); diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..203b6c2 --- /dev/null +++ b/main.ts @@ -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[]; + 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 { + 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 { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + 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 { + 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 { + 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(); + } + }) + ); + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..379821e --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d735277 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/parse.ts b/parse.ts new file mode 100644 index 0000000..0cbb9c8 --- /dev/null +++ b/parse.ts @@ -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: '." }; + } + + result.sql = rest; + return result; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..fd3d6ee --- /dev/null +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6c2dabf --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +# Configuración de pnpm: permite el script de build de esbuild (descarga su binario). +allowBuilds: + esbuild: true diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..1d9f2a7 --- /dev/null +++ b/styles.css @@ -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); +} diff --git a/tests/parse.test.ts b/tests/parse.test.ts new file mode 100644 index 0000000..82ff677 --- /dev/null +++ b/tests/parse.test.ts @@ -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); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..14a3b89 --- /dev/null +++ b/tsconfig.json @@ -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"] +}