Compare commits

...

8 commits
1.1.5 ... main

Author SHA1 Message Date
qf3l3k
e1ef155df4 release: 1.2.1 2026-05-20 17:47:34 +02:00
qf3l3k
ac1e6e239a fix: improve mobile REST response parsing 2026-05-20 17:41:54 +02:00
qf3l3k
121562cfaf Merge branch 'feature-1.2.0-templates-test-endpoint' 2026-05-02 21:03:04 +02:00
qf3l3k
2b1c33479d docs: rewrite README as plugin guide 2026-05-02 21:00:01 +02:00
qf3l3k
69dfd41ee8 feat: add template output and endpoint testing 2026-05-02 20:46:54 +02:00
qf3l3k
6a839eeaea Merge branch 'main' of gitlab.qf3l3k.lab:obsidian/obsidian-data-fetcher 2026-05-02 12:33:22 +02:00
qf3l3k
5d2cc8fb58 Updated .gitignore. 2026-05-02 12:31:46 +02:00
qf3l3k
011a291cfc Merge branch 'feature-1.1.5-hardening' 2026-05-02 12:02:25 +02:00
10 changed files with 981 additions and 221 deletions

431
.gitignore vendored
View file

@ -20,3 +20,434 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# ===========================
# 🔹 IDE & Editor Settings 🔹
# ===========================
# JetBrains IDEs (PyCharm, IntelliJ, etc.)
.idea/
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/shelf
.idea/**/dictionaries
.idea/httpRequests
.idea/caches/
.idea/**/dataSources/
.idea/**/sonarlint/
.idea/**/aws.xml
.idea/**/contentModel.xml
.idea/**/gradle.xml
.idea/**/libraries
.idea/**/mongoSettings.xml
# Visual Studio Code
.vscode/
# Jupyter Notebook
.ipynb_checkpoints/
# Spyder IDE
.spyderproject
.spyproject
# Rope (Python refactoring library)
.ropeproject/
# Sublime Text
*.sublime-workspace
*.sublime-project
# Eclipse
.metadata/
bin/
tmp/
# ===========================
# 🔹 Python Build & Packaging 🔹
# ===========================
# Virtual environments
.venv/
venv/
env/
ENV/
env.bak/
venv.bak/
.Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.so # Cython extensions
# Build directories & distribution artifacts
build/
dist/
downloads/
eggs/
.eggs/
wheels/
*.egg-info/
.installed.cfg
*.egg
share/python-wheels/
MANIFEST
# Configuration with secrets
config.yaml
config.yml
config.json
settings.json
# ===========================
# 🔹 Extra Python Tooling 🔹
# ===========================
# PyInstaller
*.spec
*.manifest
# Translations
*.mo
*.pot
# pyenv
.python-version
# CMake
cmake-build-*/
# Legacy pip
pip-selfcheck.json
# ===========================
# 🔹 Dependency Management 🔹
# ===========================
# Pip, Pipenv, Poetry, PDM
pip-log.txt
pip-delete-this-directory.txt
Pipfile.lock
poetry.lock
pdm.lock
.pdm.toml # PDM project-wide configurations / Security reports
.pdm-build/
# Pyflow & PEP 582
__pypackages__/
# UV package manager
.uv/
# Ruff cache
.ruff_cache/
# pdm backend
security-report.*
bandit-report.*
*.sarif
# ===========================
# 🔹 Testing & Coverage 🔹
# ===========================
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
dmypy.json
.dmypy.json
# Mypy type checker
.mypy_cache/
.pyre/
.pytype/
cython_debug/
# ===========================
# 🔹 Logs & Temporary Files 🔹
# ===========================
# Log files
*.log
*.out
*.err
# Log folders
logs/
.logs/
# Cache files / Local runtime data
.cache/
*.pid
*.pid.lock
*.seed
*.bak
*.swp
*.swo
*.swn
# OS-generated files (Mac, Linux, Windows)
.DS_Store
Thumbs.db
desktop.ini
~$*
# ===========================
# 🔹 Framework-Specific 🔹
# ===========================
# Django
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask
instance/
.webassets-cache
# Scrapy
.scrapy/
# Sphinx Documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Celery
celerybeat-schedule
celerybeat.pid
# ================================
# 🔐 Secrets & Local Credentials 🔐
# ================================
# NEVER commit secrets, credentials, or sensitive configuration!
# Authentication tokens & API keys
token.pickle
*.pem
*.key
*.p12
*.pfx
*.jks
*.keystore
# Credential files / Python keyrings / local auth stores
credentials.json
service-account-key.json
*.keyring
*.kdb
*.kdbx
.keyring/
.keyrings/
# API keys & secrets in files
api_keys.py
secrets.py
_config_secret.py
# OAuth tokens / Session data / API token caches
*.token
*.oauth
*.session
*.gpg
*.pickle
*.pkl
*.pickle.*
*.pkl.*
token.json
credentials.json
client_secret*.json
oauth*.json
# Environment files (usually contain secrets)
.env
.env.local
.env.*
!.env.example # Keep example files
!.env.template # Keep template files
.envrc
# Password files
.passwords
passwords.txt
secrets.txt
# Certificate files (unless intentionally public)
*.crt
*.cert
*.der
# Cloud provider credentials & CLI caches
# AWS
.aws/
# (If you only want to ignore credentials, use:)
# .aws/credentials
# .aws/config
# Google Cloud
.gcloud/
# Azure
.azure/
# Kubernetes configs (often sensitive)
.kube/
kubeconfig
# ===========================
# 🎬 Media outputs (local) 🎬
# ===========================
renders/
render/
exports/
export/
outputs/
output/
tmp_media/
.cache_media/
# Generated media (video/audio/image exports)
*.mp4
*.mov
*.mkv
*.webm
*.avi
*.mp3
*.wav
*.flac
*.aac
*.m4a
*.ogg
*.psd
*.afphoto
*.afdesign
*.afpub
*.blend
*.aep
*.prproj
*.drp
*.gif
*.tif
*.tiff
*.bmp
# Documents
*.pdf
*.doc
*.docx
*.xls
*.xlsx
*.ppt
*.pptx
# Archives often used for packaging exports
*.zip
*.7z
*.rar
# ===========================
# 🔹 Other Configurations 🔹
# ===========================
# Database files (generic)
*.db
*.sqlite
*.sqlite3
!docs/demo/*.sqlite
*.db-journal
*.sqlite3-journal
*.fdb
*.mdb
# Docker & Containerization
docker-compose.override.yml
# Cloud/Infrastructure secrets
*.tfstate
*.tfstate.backup
.terraform/
terraform.tfvars
terraform.tfvars.json
# Ansible vault files
vault.yml
*.vault
*.vault.yml
# =========================================
# 🔹Local project planning / prompt files🔹
# =========================================
prompt.md
requirements.md
private/
.private/
# =========================================
# 🤖 Generative AI & coding assistants 🤖
# =========================================
# Claude Code (Anthropic)
.claude/
CLAUDE.local.md
CLAUDE.md
# Cursor
.cursor/
.cursorrules
.cursorignore
# GitHub Copilot
.github/copilot-instructions.md
.copilot/
# Windsurf / Codeium
.windsurf/
.codeium/
# OpenAI Codex CLI
.codex/
AGENTS.md
codex.md
# ChatGPT / OpenAI
.openai/
# Aider
.aider*
aider.conf.yml
# Continue
.continue/
# Generic AI artefacts
*.ai-context
*.ai-notes
ai-notes/
ai-context/
scratchpad.md
todo.md
TODO.md
todos.md
TODOS.md
notes.md
NOTES.md

View file

@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file.
## [1.2.1] - 2026-05-20
### Fixed
- Improved REST response parsing for mobile platforms by handling `Content-Type` headers case-insensitively and falling back to JSON body parsing when the payload is JSON-like. This fixes `path` lookups such as `path: 0` failing on Android and iPad for endpoints that return JSON arrays.
## [1.2.0] - 2026-05-02
### Added
- Added Markdown template output with `template:` for alias and direct JSON queries.
- Template placeholders support nested dot paths, for example `{{owner.login}}`.
- Array responses render one template result per item, joined by new lines.
- Added `Test endpoint` action in the endpoint editor to verify an unsaved endpoint draft without writing cache or frontmatter.
### Changed
- Template output now drives rendered output, Copy, and Save to Note actions when provided.
- Template output takes precedence over `format: table`; frontmatter output still writes raw selected data.
## [1.1.5] - 2026-05-02
### Added

434
README.md
View file

@ -1,60 +1,95 @@
# Data Fetcher
Data Fetcher is an [Obsidian](https://obsidian.md) plugin that runs requests against external data endpoints and renders the result directly in notes.
Data Fetcher is an [Obsidian](https://obsidian.md) plugin that fetches data from external endpoints and renders it directly inside notes.
<a href="https://buymeacoffee.com/qf3l3k">
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" width="180">
</a>
Supported endpoint types:
- REST APIs
- GraphQL endpoints
- RPC endpoints
- gRPC-style endpoints via HTTP proxy
Use it to query REST APIs, GraphQL endpoints, JSON-RPC APIs, or gRPC-style HTTP proxy endpoints from `data-query` code blocks. Results can be rendered as JSON, tables, Markdown templates, copied to clipboard, saved into the note, cached, or written into note frontmatter.
## Highlights
## Features
- `data-query` code block processor for live request execution
- Endpoint alias configuration in plugin settings
- Compact endpoint list with modal editor (v1.1.1)
- Endpoint import/export for moving aliases between devices (v1.1.2)
- Endpoint filtering in settings and safer header-free exports by default (v1.1.5)
- Cache with configurable expiration
- Cache browser modal (list, preview, delete individual entries)
- Optional ribbon icon shortcut for cache browser
- Per-block refresh button
- Command to refresh all queries in current note
- Copy result and Save to Note actions
- Custom headers for authenticated requests
- Query REST, GraphQL, RPC, and gRPC-style HTTP proxy endpoints.
- Use direct JSON requests or reusable endpoint aliases from plugin settings.
- Configure custom headers for authenticated APIs.
- Test endpoint configuration before saving it.
- Shape output with dot-path selection.
- Render output as JSON, table, or Markdown template.
- Write selected values into note frontmatter/properties.
- Cache responses with configurable expiration.
- Browse, preview, filter, delete, or clear cached responses.
- Refresh one rendered block or all `data-query` blocks in the active note.
- Copy rendered output or save it as static Markdown in the current note.
- Import/export endpoint aliases between devices, with headers excluded by default.
## Installation
### Community Plugins
1. Open `Settings -> Community Plugins`.
1. Open `Settings -> Community Plugins` in Obsidian.
2. Search for `Data Fetcher`.
3. Install and enable.
3. Install and enable the plugin.
### Manual
### Manual Install
1. Download release assets from [GitHub releases](https://github.com/qf3l3k/obsidian-data-fetcher/releases).
2. Copy `manifest.json`, `main.js`, and `styles.css` into:
`.obsidian/plugins/data-fetcher`
3. Reload Obsidian and enable the plugin.
1. Download `manifest.json`, `main.js`, and `styles.css` from the latest [GitHub release](https://github.com/qf3l3k/obsidian-data-fetcher/releases).
2. Copy those files into `.obsidian/plugins/data-fetcher` in your vault.
3. Reload Obsidian and enable `Data Fetcher`.
## How It Works
## Quick Start
Create a code block with language `data-query`.
The plugin parses the block, executes the query, caches the response, and renders output below the block.
## Query Syntax
### Direct JSON Query (all endpoint types)
Create a fenced code block with language `data-query`:
```data-query
{
"type": "rest",
"url": "https://api.example.com/data",
"url": "https://api.github.com/users/octocat/repos",
"method": "GET",
"path": "0",
"template": "First repo: [{{name}}]({{html_url}})"
}
```
When the note is rendered, Data Fetcher executes the request, caches the response, and renders the selected output below the block.
## Endpoint Aliases
For repeated use, configure endpoints in `Settings -> Data Fetcher` and reference them by alias.
Example endpoint:
- Alias: `github-repos`
- Type: `REST`
- URL: `https://api.github.com/users/octocat/repos`
- Method: `GET`
Then use it in a note:
```data-query
@github-repos
path: 0
template: First repo: [{{name}}]({{html_url}})
```
Endpoint settings support:
- Alias, type, URL, method, and headers.
- Compact endpoint list with filtering by alias, type, or URL.
- Edit, duplicate, and delete actions.
- Endpoint test button in the add/edit dialog.
- Import/export for moving endpoint lists between devices.
Header exports are disabled by default so API keys and tokens are not accidentally shared.
## Query Types
### REST
```data-query
{
"type": "rest",
"url": "https://api.example.com/items",
"method": "GET",
"headers": {
"Authorization": "Bearer your-token"
@ -62,201 +97,220 @@ The plugin parses the block, executes the query, caches the response, and render
}
```
### Alias Query
Configure alias in plugin settings, then reference it:
```data-query
@my-api-alias
body: {"id": 123}
```
### Alias With Inline Variables (Issue #5, v1.0.7)
```data-query
@github-api({"first": 5, "after": null})
query: query($first: Int, $after: String) { viewer { repositories(first: $first, after: $after) { nodes { name } } } }
```
Equivalent variant:
```data-query
=@github-api({"first": 5})
query: query($first: Int) { viewer { repositories(first: $first) { nodes { name } } } }
```
Notes:
- Inline variables must be a valid JSON object.
- Explicit `variables:` entry later in the block overrides inline variables.
### Output Shaping (Issue #6, v1.0.8)
You can control rendered output with:
- `path`: selects nested data using dot notation
- `format`: `json` (default) or `table`
Example:
```data-query
@bitsong
query: query MyQuery { nftTokens { edges { node { id minter } } } }
path: nftTokens.edges
format: table
```
Notes:
- `table` works on arrays of objects.
- If `format: table` is used on unsupported data, plugin falls back to JSON output.
- Paths can include array indexes, for example `data.items.0`.
### Frontmatter Output (Issue #2, in progress for v1.0.9)
You can write fetched output into note properties/frontmatter:
```data-query
@bitsong
query: query MyQuery { nftTokens { edges { node { id minter } } } }
path: nftTokens.edges.0.node.id
output: frontmatter
property: external.firstTokenId
```
Notes:
- `output: frontmatter` writes selected data to the current note frontmatter.
- `property` is required and supports dot-path notation (for nested properties).
- This mode updates current note metadata; it does not create new notes.
## Endpoint Type Reference
### REST
Common fields:
- `type`: `rest`
- `url`: endpoint URL
- `method`: `GET` | `POST` | `PUT` | `DELETE`
- `headers`: object
- `body`: object or string
REST supports `GET`, `POST`, `PUT`, and `DELETE`. Use `body` for request payloads.
### GraphQL
Common fields:
- `type`: `graphql`
- `url`: GraphQL endpoint URL
- `query`: GraphQL query string
- `variables`: JSON object
- `path`: optional dot-path selector for rendered data
- `format`: `json` | `table` for rendered output
- `headers`: object
Example:
```data-query
{
"type": "graphql",
"url": "https://indexer-bs721-base.bitsong.io/",
"query": "query MyQuery { nftTokens { edges { node { id minter } } } }",
"variables": {}
"url": "https://api.example.com/graphql",
"query": "query($first: Int) { viewer { repositories(first: $first) { nodes { name url } } } }",
"variables": {
"first": 5
},
"path": "data.viewer.repositories.nodes",
"format": "table"
}
```
### RPC
With an alias, inline variables can be passed at the call site:
Common fields:
- `type`: `rpc`
- `url`: RPC endpoint URL
- `method`: always `POST`
- `query`: method name
- `body`: params object
- `headers`: object
```data-query
@github-api({"first": 5})
query: query($first: Int) { viewer { repositories(first: $first) { nodes { name url } } } }
path: viewer.repositories.nodes
format: table
```
### gRPC via proxy
`=@alias({...})` is also supported for inline-style calls.
Common fields:
- `type`: `grpc`
- `url`: proxy endpoint URL
- `body`: payload object
- `headers`: object
### RPC / JSON-RPC
## Settings
```data-query
{
"type": "rpc",
"url": "https://rpc.example.com",
"query": "status",
"body": {}
}
```
Open `Settings -> Data Fetcher`.
RPC requests are sent as JSON-RPC-style `POST` requests. `query` is used as the RPC method name and `body` is used as params.
Available options:
- Cache duration (minutes)
- Endpoint aliases (compact list with Name/Type/URL + actions)
- Endpoint import/export (JSON)
- Endpoint filter by alias, type, or URL
- Per-alias headers
- Cache clearing
- Cache browser shortcut
- Cache browser ribbon icon toggle
- Cache info preview (item count/size)
### gRPC via HTTP Proxy
```data-query
{
"type": "grpc",
"url": "https://proxy.example.com/my.Service/GetItem",
"body": {
"id": "123"
}
}
```
Obsidian does not provide native gRPC transport. This mode is intended for gRPC services exposed through an HTTP/JSON proxy.
## Output Options
### Select Data With `path`
Use dot notation to select nested response data:
```data-query
@github-repos
path: 0.owner.login
```
Paths can include array indexes, for example `items.0.name`.
### Render as JSON
JSON is the default output format:
```data-query
@github-repos
path: 0
format: json
```
### Render as a Table
Tables work best with arrays of objects:
```data-query
@github-repos
path: data
format: table
```
If table rendering cannot find an array of objects, the plugin falls back to JSON output.
### Render With a Markdown Template
Templates turn API data into note-ready Markdown:
```data-query
@github-repos
path: data
template: - [{{name}}]({{html_url}}) by {{owner.login}}
```
Template rules:
- `{{field}}` inserts a field from the selected object.
- `{{owner.login}}` supports nested fields.
- Missing values render as empty strings.
- Arrays render one template result per item, joined with new lines.
- Primitive values can be referenced with `{{value}}`.
- `template` takes precedence over `format: table`.
### Write to Frontmatter
Use `output: frontmatter` to write selected data into note properties:
```data-query
@github-repos
path: 0.name
output: frontmatter
property: external.firstRepo
```
Notes:
- `property` is required.
- Dot-path properties are supported, for example `external.github.firstRepo`.
- Frontmatter writes use raw selected data, not rendered template text.
- Cached render results do not write frontmatter; fresh fetches and manual refreshes do.
## Rendered Block Actions
Each rendered result includes:
- `Refresh`: reruns the query and updates the cache.
- `Copy`: copies the rendered output.
- `Save to Note`: inserts/replaces static Markdown output in the current note.
## Commands
- `Refresh data query`: refreshes all `data-query` blocks in the active note and updates cache.
- `Open cache browser`: opens cache browser modal for cache inspection and management.
Data Fetcher adds these commands:
## Actions in Rendered Block
- `Refresh data query`: refresh all `data-query` blocks in the active note.
- `Open cache browser`: inspect and manage cached responses.
- `Refresh`: reruns that query and updates cached value.
- `Copy`: copies rendered response to clipboard.
- `Save to Note`: replaces/inserts static result in current markdown file.
## Cache Management
## Caching Behavior
Data Fetcher stores cached responses in `.data-fetcher-cache` in the vault root.
- Cache location: `.data-fetcher-cache` in vault root.
- Keying: deterministic hash of query parameters.
- Expiration: controlled by `Cache duration` setting.
- Manual refresh bypasses stale content by re-executing query and writing fresh cache.
In settings, you can:
### Cache Browser
- Set cache duration in minutes.
- Clear all cached responses.
- Open the cache browser.
- Enable an optional ribbon icon for quick cache browser access.
Use command `Open cache browser` (or settings button) to:
- list cache entries with size/date
- filter entries by alias, key, type, or URL
- preview cached payloads
- delete individual entries
- clear all cache
The cache browser can:
- List cached entries with alias-aware labels, size, date, type, and URL.
- Filter entries by alias, hash, type, or URL.
- Preview cached payloads.
- Delete individual entries.
- Clear all entries.
## Import and Export
Endpoint configurations can be exported to JSON and imported on another device.
Export behavior:
- Headers are excluded by default.
- Enable `Include headers` only when you intentionally want to export secrets such as Authorization tokens.
Import behavior:
- `Merge` updates matching aliases and adds new ones.
- `Replace` overwrites the current endpoint list.
- Invalid or duplicate entries are skipped with a summary.
## Troubleshooting
- `Endpoint alias "..." not found`: add or fix alias in settings.
- `Variables must be valid JSON`: ensure valid JSON syntax (`{"x": 1}` not `{x: 1}`).
- `Path "..." not found`: check nested field names/indexes in response data.
- `Table format requires an array of objects`: update `path` to point at an object array, or use `format: json`.
- `property is required when output: frontmatter is used`: add a property path.
- `Endpoint alias "..." not found`: add the alias in settings or fix the alias name in the note.
- `Variables must be valid JSON`: use valid JSON, for example `{"first": 5}`.
- `Path "..." not found`: verify the response shape and the selected path.
- `Table format requires an array of objects`: point `path` at an array of objects or use JSON/template output.
- Blank template placeholders: check field names against the data selected by `path`.
- `property is required when output: frontmatter is used`: add a `property` value.
- GraphQL endpoint test fails with missing query: endpoint testing checks the saved endpoint draft; run complete GraphQL queries from a note block.
- `Response too large for this device`: reduce payload size, add filters/limits, or use a proxy endpoint.
- No result refresh from command: ensure active pane is a markdown file.
- Build fails on PowerShell script policy: run via `cmd /c npm run build`.
- No command refresh happens: make sure the active pane is a Markdown note.
## Privacy and Data Disclosure
This plugin communicates with external services and stores response data locally.
- Network usage: sends HTTP(S) requests to endpoints configured in notes or settings.
- External dependency: uses Obsidian's built-in `requestUrl` API.
- Data sent: URL, method, headers, body, query, and variables you configure.
- Data stored locally: plugin settings and cached responses in `.data-fetcher-cache`.
- Data shared externally: only with endpoints you configure.
## Development
Build:
```powershell
cmd /c npm install
cmd /c npm run build
```bash
npm install
npm run build
```
Watch mode:
```powershell
cmd /c npm run dev
```bash
npm run dev
```
Test in vault by linking/copying plugin files to:
`.obsidian/plugins/data-fetcher`
## Data Disclosure
This plugin communicates with external services and stores response data:
- Network usage: Sends HTTP(S) requests to endpoints configured in notes/settings.
- External dependencies: Uses Obsidian built-in `requestUrl` API.
- Data sent: URL, method, headers, and optional body/query/variables you configure.
- Data stored locally: plugin settings and cached responses in `.data-fetcher-cache`.
- Data shared externally: only with endpoints you configure.
Test in a vault by linking or copying plugin files to `.obsidian/plugins/data-fetcher`.
## Support

169
main.ts
View file

@ -1,4 +1,4 @@
import { App, Editor, EventRef, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
import { App, Editor, EventRef, MarkdownRenderer, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
import { DataFetcherSettings, DEFAULT_SETTINGS, EndpointConfig } from './src/settings';
import { parseDataQuery, executeQuery, QueryParams, QueryResult } from './src/queryEngine';
import { CacheManager } from './src/cacheManager';
@ -444,6 +444,68 @@ export default class DataFetcherPlugin extends Plugin {
return [headerLine, dividerLine, ...rowLines].join('\n');
}
private resolveTemplatePath(data: any, path: string): any {
const normalizedPath = path.trim();
if (!normalizedPath) {
return '';
}
if (normalizedPath === 'value' && (data === null || typeof data !== 'object')) {
return data;
}
const segments = normalizedPath.split('.').map(segment => segment.trim()).filter(Boolean);
let current = data;
for (const segment of segments) {
if (current === null || current === undefined) {
return '';
}
if (Array.isArray(current)) {
const index = Number(segment);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return '';
}
current = current[index];
continue;
}
if (typeof current === 'object' && segment in current) {
current = current[segment];
continue;
}
return '';
}
return current;
}
private templateValueToString(value: any): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
private renderTemplate(template: string, data: any): string {
return template.replace(/{{\s*([^}]+?)\s*}}/g, (_match, path) => {
return this.templateValueToString(this.resolveTemplatePath(data, String(path)));
});
}
private renderTemplateOutput(template: string, data: any): string {
if (Array.isArray(data)) {
return data.map(item => this.renderTemplate(template, item)).join('\n');
}
return this.renderTemplate(template, data);
}
renderResult(result: QueryResult, container: HTMLElement, query?: QueryParams, ctx?: any) {
// First clear the container
container.empty();
@ -550,6 +612,9 @@ export default class DataFetcherPlugin extends Plugin {
if (selectedData === null || selectedData === undefined) {
outputText = 'No data returned';
content.setText(outputText);
} else if (query?.template?.trim()) {
outputText = this.renderTemplateOutput(query.template, selectedData);
void MarkdownRenderer.render(this.app, outputText, content, ctx?.sourcePath || '', this);
} else if (format === 'table') {
const tableInput = this.tryResolveTableInput(selectedData);
const resolvedTableInput = this.buildTableData(tableInput)
@ -1260,6 +1325,59 @@ class EndpointExportModal extends Modal {
}
}
class EndpointTestResultModal extends Modal {
private query: QueryParams;
private result: QueryResult;
private durationMs: number;
constructor(app: App, query: QueryParams, result: QueryResult, durationMs: number) {
super(app);
this.query = query;
this.result = result;
this.durationMs = durationMs;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
this.modalEl.addClass('data-fetcher-endpoint-test-modal');
new Setting(contentEl)
.setName('Endpoint test result')
.setHeading();
const statusText = this.result.error ? 'Failed' : 'Success';
contentEl.createEl('div', {
text: statusText,
cls: this.result.error ? 'data-fetcher-endpoint-test-status-error' : 'data-fetcher-endpoint-test-status-success'
});
const meta = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-test-meta' });
meta.createEl('div', { text: `Type: ${this.query.type}` });
meta.createEl('div', { text: `URL: ${this.query.url || ''}` });
meta.createEl('div', { text: `Duration: ${Math.round(this.durationMs)} ms` });
if (this.result.error) {
contentEl.createEl('div', { text: this.result.error, cls: 'data-fetcher-error' });
}
const previewValue = this.result.error ? this.result.data : this.result.data;
const previewText = typeof previewValue === 'string'
? previewValue
: JSON.stringify(previewValue, null, 2);
const pre = contentEl.createEl('pre', { cls: 'data-fetcher-endpoint-test-preview' });
pre.createEl('code', { text: previewText || '(empty response)' });
const actions = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-editor-actions' });
actions.createEl('button', { text: 'Close', cls: 'mod-cta' }).addEventListener('click', () => this.close());
}
onClose() {
this.modalEl.removeClass('data-fetcher-endpoint-test-modal');
this.contentEl.empty();
}
}
class EndpointEditorModal extends Modal {
private endpoint: EndpointConfig;
private existingAliases: Set<string>;
@ -1325,6 +1443,52 @@ class EndpointEditorModal extends Modal {
}));
}
private buildTestQuery(): QueryParams {
const url = this.endpoint.url?.trim();
if (!url) {
throw new Error('URL is required before testing.');
}
const alias = this.endpoint.alias?.trim() || 'endpoint-test';
const method = this.endpoint.type === 'rest'
? (this.endpoint.method || 'GET')
: 'POST';
return {
endpoint: alias,
type: this.endpoint.type,
url,
method,
headers: { ...(this.endpoint.headers || {}) },
body: this.endpoint.body,
query: this.endpoint.query
};
}
private async testEndpoint(button: HTMLButtonElement): Promise<void> {
let query: QueryParams;
try {
query = this.buildTestQuery();
} catch (error) {
new Notice(error.message);
return;
}
button.disabled = true;
const originalText = button.textContent || 'Test endpoint';
button.setText('Testing...');
const startedAt = performance.now();
try {
const result = await executeQuery(query);
const durationMs = performance.now() - startedAt;
new EndpointTestResultModal(this.app, query, result, durationMs).open();
} finally {
button.disabled = false;
button.setText(originalText);
}
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
@ -1391,6 +1555,9 @@ class EndpointEditorModal extends Modal {
const actions = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-editor-actions' });
actions.createEl('button', { text: 'Cancel' }).addEventListener('click', () => this.close());
actions.createEl('button', { text: 'Test endpoint' }).addEventListener('click', async (event) => {
await this.testEndpoint(event.currentTarget as HTMLButtonElement);
});
actions.createEl('button', { text: 'Save', cls: 'mod-cta' }).addEventListener('click', () => {
const normalizedAlias = this.endpoint.alias?.trim();
if (!normalizedAlias) {

View file

@ -1,7 +1,7 @@
{
"id": "data-fetcher",
"name": "Data Fetcher",
"version": "1.1.5",
"version": "1.2.1",
"minAppVersion": "1.8.3",
"description": "Fetch data from multiple sources (REST APIs, RPC, gRPC, GraphQL) and insert results into notes",
"author": "qf3l3k",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-api-fetcher",
"version": "1.1.5",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-api-fetcher",
"version": "1.1.5",
"version": "1.2.1",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-api-fetcher",
"version": "1.1.5",
"version": "1.2.1",
"description": "Fetch data from multiple sources (REST APIs, RPC, gRPC, GraphQL) and insert results into notes",
"main": "main.js",
"scripts": {

View file

@ -7,6 +7,7 @@ export interface QueryParams {
output?: 'render' | 'frontmatter';
property?: string;
format?: 'json' | 'table';
template?: string;
path?: string;
url?: string;
method?: string;
@ -22,6 +23,32 @@ export interface QueryResult {
error?: string;
}
function findHeaderValue(headers: Record<string, string>, targetName: string): string | undefined {
const normalizedTarget = targetName.toLowerCase();
for (const [name, value] of Object.entries(headers || {})) {
if (name.toLowerCase() === normalizedTarget) {
return value;
}
}
return undefined;
}
function tryParseJsonText(text: string): any | undefined {
const trimmed = text.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
function formatRequestError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
@ -80,6 +107,32 @@ function parseAliasReferenceLine(aliasLine: string): { alias: string; inlineVari
}
}
function isKnownParameterLine(line: string): boolean {
return /^(body|query|variables|path|format|output|property|template)\s*:/i.test(line.trim());
}
function collectMultilineValue(lines: string[], startIndex: number, initialValue: string): { value: string; endIndex: number } {
let value = initialValue;
let endIndex = startIndex;
for (let j = startIndex + 1; j < lines.length; j++) {
const nextLine = lines[j];
const trimmedNextLine = nextLine.trim();
if (trimmedNextLine && isKnownParameterLine(trimmedNextLine)) {
break;
}
value += '\n' + nextLine;
endIndex = j;
}
return {
value: value.trim(),
endIndex
};
}
/**
* Parse the data query from the codeblock
*/
@ -122,44 +175,39 @@ export function parseDataQuery(source: string, settings: DataFetcherSettings): Q
// Check if it's a parameter assignment
if (line.includes(':')) {
const [key, ...valueParts] = line.split(':');
const normalizedKey = key.trim().toLowerCase();
let value = valueParts.join(':').trim();
// Handle multi-line values (especially for GraphQL queries)
if (key.trim() === 'query' && i + 1 < lines.length) {
// Collect all lines until we hit a line that looks like a new parameter
for (let j = i + 1; j < lines.length; j++) {
const nextLine = lines[j].trim();
if (nextLine && !nextLine.includes(':')) {
value += '\n' + nextLine;
i = j; // Update the outer loop counter
} else if (nextLine.includes(':')) {
break; // Stop when we hit a new parameter
}
}
if ((normalizedKey === 'query' || normalizedKey === 'template') && i + 1 < lines.length) {
const collected = collectMultilineValue(lines, i, value);
value = collected.value;
i = collected.endIndex;
}
if (key.trim() === 'body') {
if (normalizedKey === 'body') {
try {
queryParams.body = JSON.parse(value);
} catch {
queryParams.body = value;
}
} else if (key.trim() === 'query') {
} else if (normalizedKey === 'query') {
queryParams.query = value;
} else if (key.trim() === 'variables') {
} else if (normalizedKey === 'variables') {
try {
queryParams.variables = JSON.parse(value);
} catch {
throw new Error('Variables must be valid JSON');
}
} else if (key.trim() === 'path') {
} else if (normalizedKey === 'path') {
queryParams.path = value;
} else if (key.trim() === 'format') {
} else if (normalizedKey === 'format') {
queryParams.format = parseOutputFormat(value);
} else if (key.trim() === 'output') {
} else if (normalizedKey === 'output') {
queryParams.output = parseOutputTarget(value);
} else if (key.trim() === 'property') {
} else if (normalizedKey === 'property') {
queryParams.property = value;
} else if (normalizedKey === 'template') {
queryParams.template = value;
}
}
}
@ -269,14 +317,17 @@ async function executeRestQuery(params: QueryParams): Promise<any> {
const response = await requestUrl(requestParams);
// Parse response based on content type
const contentType = response.headers['content-type'];
if (contentType && contentType.includes('application/json')) {
// Mobile builds may expose response headers with different casing, so
// treat Content-Type lookup as case-insensitive and fall back to parsing
// the body when JSON-looking payloads arrive without a usable header.
const contentType = findHeaderValue(response.headers || {}, 'content-type');
if (contentType && contentType.toLowerCase().includes('application/json')) {
return response.json;
} else {
return response.text;
}
const parsedText = tryParseJsonText(response.text);
return parsedText !== undefined ? parsedText : response.text;
}
/**

View file

@ -218,6 +218,44 @@
width: 100%;
}
.data-fetcher-endpoint-test-modal {
width: min(760px, 92vw);
max-width: min(760px, 92vw);
}
.data-fetcher-endpoint-test-status-success,
.data-fetcher-endpoint-test-status-error {
margin: 8px 0 12px;
font-weight: 600;
}
.data-fetcher-endpoint-test-status-success {
color: var(--text-success);
}
.data-fetcher-endpoint-test-status-error {
color: var(--text-error);
}
.data-fetcher-endpoint-test-meta {
display: grid;
gap: 4px;
margin-bottom: 12px;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
overflow-wrap: anywhere;
}
.data-fetcher-endpoint-test-preview {
max-height: 420px;
overflow: auto;
padding: 10px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background: var(--background-secondary);
white-space: pre-wrap;
}
.data-fetcher-endpoint-editor-actions {
display: flex;
justify-content: flex-end;

View file

@ -9,5 +9,7 @@
"1.1.2": "1.8.3",
"1.1.3": "1.8.3",
"1.1.4": "1.8.3",
"1.1.5": "1.8.3"
"1.1.5": "1.8.3",
"1.2.0": "1.8.3",
"1.2.1": "1.8.3"
}