From 011ccfb8cdd8fa56b1f6c5541791b4469c0a8c63 Mon Sep 17 00:00:00 2001 From: agent Date: Fri, 12 Jun 2026 23:58:04 +0200 Subject: [PATCH] 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 --- .gitignore | 2 + app.md | 120 ++++++++++++++ deploy.sh | 19 +++ esbuild.config.mjs | 28 ++++ main.ts | 255 ++++++++++++++++++++++++++++++ manifest.json | 9 ++ package.json | 18 +++ parse.ts | 80 ++++++++++ pnpm-lock.yaml | 377 ++++++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 3 + styles.css | 96 +++++++++++ tests/parse.test.ts | 65 ++++++++ tsconfig.json | 16 ++ 13 files changed, 1088 insertions(+) create mode 100644 .gitignore create mode 100644 app.md create mode 100755 deploy.sh create mode 100644 esbuild.config.mjs create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 package.json create mode 100644 parse.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 styles.css create mode 100644 tests/parse.test.ts create mode 100644 tsconfig.json 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"] +}