Compare commits

..

No commits in common. "main" and "1.1.1" have entirely different histories.
main ... 1.1.1

14 changed files with 516 additions and 1528 deletions

431
.gitignore vendored
View file

@ -20,434 +20,3 @@ 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,61 +2,7 @@
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
- Added endpoint list filtering in settings by alias, type, or URL.
- Added an export confirmation dialog with an explicit `Include headers` option for endpoint JSON exports.
### Changed
- Endpoint exports now omit headers by default to avoid accidentally sharing API keys or tokens.
- Frontmatter output is no longer applied from cached render results; it still writes after fresh fetches and manual refreshes.
## [1.1.4] - 2026-03-21
### Added
- Cache browser entries now display alias-aware labels in the form `<alias/direct> - <hash>`.
- Cache browser filtering now matches alias, hash, type, and URL.
### Changed
- Endpoint editor dialog now gives the URL field more horizontal space for long endpoints.
- Cache browser list and preview panes now use more of the available dialog space.
- New cache entries store lightweight query metadata so cache browser labels are easier to understand.
## [1.1.3] - 2026-03-21
### Fixed
- Cache keys now include endpoint identity and headers to avoid cross-endpoint cache collisions.
- RPC requests now always use `POST`, matching JSON-RPC expectations and improving mobile compatibility.
- Endpoint editor now blocks duplicate aliases, and endpoint import deduplicates aliases before merge/replace.
- `Save to Note` no longer falls back to replacing the first `data-query` block when source block location is unavailable.
- Large-response request errors now surface a clearer hint about reducing payload size or using a proxy.
## [1.1.2] - 2026-03-03
### Added
- Endpoint import/export in settings for moving alias configurations between devices.
- Import supports both full export payload (`{ version, exportedAt, endpoints }`) and plain endpoint array JSON.
### Changed
- Endpoint import now supports `Merge` (update by alias + append new) and `Replace` (overwrite all current endpoints) modes with validation/skip summary.
## [Unreleased]
## [1.1.1] - 2026-03-03
@ -69,7 +15,7 @@ All notable changes to this project will be documented in this file.
- Updated endpoint settings layout for better readability when many aliases exist.
- Reduced endpoint list font size and tightened spacing to better match Obsidian settings styling and avoid clipped action buttons.
## [1.1.0] - 2026-03-03
## [1.1.0] - 2026-03-02
### Added
- Issue #5 (cache management): added Cache Browser modal with entry list, payload preview, single-entry deletion, and clear-all action.
@ -78,12 +24,12 @@ All notable changes to this project will be documented in this file.
### Changed
- Improved cache browser UX with larger modal layout and cache-key filtering.
## [1.0.9] - 2026-03-03
## [1.0.9] - 2026-03-02
### Added
- Issue #2 (first step): support `output: frontmatter` with `property` path to store selected query results in current note metadata.
## [1.0.8] - 2026-03-03
## [1.0.8] - 2026-03-02
### Added
- Issue #6: added optional `path` selector and `format: table` rendering for array-of-object JSON responses.
@ -92,7 +38,7 @@ All notable changes to this project will be documented in this file.
- Copy and Save actions now use transformed output (selected path / table view).
- Improved table readability by truncating long cell content in UI with full value on hover.
## [1.0.7] - 2026-03-03
## [1.0.7] - 2026-03-02
### Added
- Issue #5: support inline call-site variables for endpoint aliases using `@alias({...})` and `=@alias({...})`.
@ -100,7 +46,7 @@ All notable changes to this project will be documented in this file.
### Changed
- Expanded README with full query syntax, endpoint reference, examples, troubleshooting, and development guidance.
## [1.0.6] - 2026-03-03
## [1.0.6] - 2026-03-02
### Fixed
- Issue #3: improved error block readability in dark/light themes by switching to normal text color with a clear error border.

432
README.md
View file

@ -1,95 +1,55 @@
# Data Fetcher
Data Fetcher is an [Obsidian](https://obsidian.md) plugin that fetches data from external endpoints and renders it directly inside notes.
Data Fetcher is an [Obsidian](https://obsidian.md) plugin that runs requests against external data endpoints and renders the result directly in 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>
[![Buy Me a Coffee](https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png)](https://buymeacoffee.com/qf3l3k)
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.
Supported endpoint types:
- REST APIs
- GraphQL endpoints
- RPC endpoints
- gRPC-style endpoints via HTTP proxy
## Features
## Highlights
- 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.
- `data-query` code block processor for live request execution
- Endpoint alias configuration in plugin settings
- 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
## Installation
### Community Plugins
1. Open `Settings -> Community Plugins` in Obsidian.
1. Open `Settings -> Community Plugins`.
2. Search for `Data Fetcher`.
3. Install and enable the plugin.
3. Install and enable.
### Manual Install
### Manual
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`.
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.
## Quick Start
## How It Works
Create a fenced code block with language `data-query`:
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)
```data-query
{
"type": "rest",
"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",
"url": "https://api.example.com/data",
"method": "GET",
"headers": {
"Authorization": "Bearer your-token"
@ -97,220 +57,196 @@ Header exports are disabled by default so API keys and tokens are not accidental
}
```
REST supports `GET`, `POST`, `PUT`, and `DELETE`. Use `body` for request payloads.
### 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
### 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://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"
"url": "https://indexer-bs721-base.bitsong.io/",
"query": "query MyQuery { nftTokens { edges { node { id minter } } } }",
"variables": {}
}
```
With an alias, inline variables can be passed at the call site:
### RPC
```data-query
@github-api({"first": 5})
query: query($first: Int) { viewer { repositories(first: $first) { nodes { name url } } } }
path: viewer.repositories.nodes
format: table
```
Common fields:
- `type`: `rpc`
- `url`: RPC endpoint URL
- `query`: method name
- `body`: params object
- `headers`: object
`=@alias({...})` is also supported for inline-style calls.
### gRPC via proxy
### RPC / JSON-RPC
Common fields:
- `type`: `grpc`
- `url`: proxy endpoint URL
- `body`: payload object
- `headers`: object
```data-query
{
"type": "rpc",
"url": "https://rpc.example.com",
"query": "status",
"body": {}
}
```
## Settings
RPC requests are sent as JSON-RPC-style `POST` requests. `query` is used as the RPC method name and `body` is used as params.
Open `Settings -> Data Fetcher`.
### 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.
Available options:
- Cache duration (minutes)
- Endpoint aliases
- Per-alias headers
- Cache clearing
- Cache browser shortcut
- Cache browser ribbon icon toggle
- Cache info preview (item count/size)
## Commands
Data Fetcher adds these 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.
- `Refresh data query`: refresh all `data-query` blocks in the active note.
- `Open cache browser`: inspect and manage cached responses.
## Actions in Rendered Block
## Cache Management
- `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.
Data Fetcher stores cached responses in `.data-fetcher-cache` in the vault root.
## Caching Behavior
In settings, you can:
- 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.
- Set cache duration in minutes.
- Clear all cached responses.
- Open the cache browser.
- Enable an optional ribbon icon for quick cache browser access.
### Cache Browser
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.
Use command `Open cache browser` (or settings button) to:
- list cache entries with size/date
- preview cached payloads
- delete individual entries
- clear all cache
## Troubleshooting
- `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 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.
- `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.
- 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`.
## Development
Build:
```bash
npm install
npm run build
```powershell
cmd /c npm install
cmd /c npm run build
```
Watch mode:
```bash
npm run dev
```powershell
cmd /c npm run dev
```
Test in a vault by linking or copying plugin files to `.obsidian/plugins/data-fetcher`.
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.
## Support

110
RELEASE_CHECKLIST.md Normal file
View file

@ -0,0 +1,110 @@
# Release Checklist (Public GitHub)
Use this flow when publishing `1.0.7`, `1.0.8`, `1.0.9`, etc. to the public repo without force-pushing `main`.
## 1) Pick release version
- Set a target version, for example: `1.0.7`.
- Ensure these files match that version:
- `manifest.json`
- `versions.json` (must include the target entry)
- `package.json`
- `package-lock.json`
## 2) Validate locally
```powershell
cmd /c npx tsc -noEmit -skipLibCheck
cmd /c npm run build
```
## 3) Create release commit/tag on internal main
```powershell
git checkout main
git pull origin main
git status --short
git add manifest.json versions.json package.json package-lock.json CHANGELOG.md README.md
git commit -m "release: 1.0.7"
git tag 1.0.7
git push origin main --tags
```
## 4) Prepare public PR branch from `public/main`
```powershell
git fetch public --tags
git checkout -B public-fix-1.0.7 public/main
```
## 5) Copy release metadata from tag to PR branch
```powershell
git checkout 1.0.7 -- manifest.json versions.json package.json package-lock.json
```
## 6) Sanity check diff (must be version metadata only)
```powershell
git diff -- manifest.json versions.json package.json package-lock.json
git status --short
```
## 7) Commit and push PR branch
```powershell
git add manifest.json versions.json package.json package-lock.json
git commit -m "public: bump release metadata to 1.0.7"
git push public public-fix-1.0.7
```
## 8) Open and merge PR
- Open:
- `https://github.com/qf3l3k/obsidian-data-fetcher/pull/new/public-fix-1.0.7`
- Merge into `public/main`.
## 9) Publish GitHub release assets for tag `1.0.7`
- Upload files built from the same tag:
- `manifest.json`
- `main.js`
- `styles.css`
## 10) Verify end-user update path
- In Obsidian:
- Check plugin version visible in Community Plugins.
- Run update from previous version and confirm it upgrades to target.
---
## Quick Command Template
Replace `1.0.7` with your target version before running:
```powershell
# 0) Variables
$V = "1.0.7"
# 1) Internal release
git checkout main
git pull origin main
cmd /c npx tsc -noEmit -skipLibCheck
cmd /c npm run build
git add manifest.json versions.json package.json package-lock.json CHANGELOG.md README.md
git commit -m "release: $V"
git tag $V
git push origin main --tags
# 2) Public PR branch
git fetch public --tags
git checkout -B "public-fix-$V" public/main
git checkout $V -- manifest.json versions.json package.json package-lock.json
git add manifest.json versions.json package.json package-lock.json
git commit -m "public: bump release metadata to $V"
git push public "public-fix-$V"
# 3) Open PR URL manually
Write-Output "Open: https://github.com/qf3l3k/obsidian-data-fetcher/pull/new/public-fix-$V"
```

634
main.ts
View file

@ -1,4 +1,4 @@
import { App, Editor, EventRef, MarkdownRenderer, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
import { App, Editor, EventRef, 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';
@ -21,6 +21,7 @@ export default class DataFetcherPlugin extends Plugin {
const cachedResult = await this.cacheManager.getFromCache(query);
if (cachedResult) {
await this.applyOutputTargetSafely(query, cachedResult, ctx);
this.renderResult(cachedResult, el, query, ctx);
} else {
el.createEl('div', { text: 'Fetching data...', cls: 'data-fetcher-loading' });
@ -444,68 +445,6 @@ 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();
@ -612,9 +551,6 @@ 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)
@ -738,8 +674,49 @@ export default class DataFetcherPlugin extends Plugin {
}
}
// If exact block location is unavailable, avoid replacing the wrong data-query block.
const cursor = editor.getCursor();
// Fallback: Try to use the Obsidian view to locate the code block
// This is a secondary approach that might work in some cases
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView && markdownView.getMode() === 'source') {
// Try to find the code block in the source by looking for data-query blocks
const text = editor.getValue();
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '```data-query') {
// Found a start marker, now find the end
let endLine = -1;
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].trim() === '```') {
endLine = j;
break;
}
}
if (endLine > i) {
// Replace the entire code block
const start = { line: i, ch: 0 };
const end = { line: endLine, ch: lines[endLine].length };
editor.transaction({
changes: [
{
from: start,
to: end,
text: commentedData
}
]
});
new Notice('Data block replaced with static content');
return;
}
}
}
}
// If all attempts to find the code block failed, insert at cursor position
const cursor = editor.getCursor();
editor.transaction({
changes: [
@ -751,7 +728,7 @@ export default class DataFetcherPlugin extends Plugin {
]
});
new Notice('Block location unavailable, so data was inserted at the cursor position');
new Notice('Data saved to note at cursor position');
} catch (error) {
console.error("Error saving data to note:", error);
@ -774,7 +751,6 @@ export default class DataFetcherPlugin extends Plugin {
class DataFetcherSettingTab extends PluginSettingTab {
plugin: DataFetcherPlugin;
private endpointFilter = '';
constructor(app: App, plugin: DataFetcherPlugin) {
super(app, plugin);
@ -846,41 +822,12 @@ class DataFetcherSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
this.plugin.updateCacheRibbonIcon();
}));
new Setting(containerEl)
.setName('Endpoint import/export')
.setDesc('Move endpoint aliases between devices using a JSON file')
.addButton(button => button
.setButtonText('Export endpoints')
.onClick(() => {
new EndpointExportModal(this.app, this.plugin.settings.endpoints, includeHeaders => {
this.exportEndpoints(includeHeaders);
}).open();
}))
.addButton(button => button
.setButtonText('Import endpoints')
.onClick(() => {
void this.importEndpoints();
}));
// Endpoint aliases section
new Setting(containerEl)
.setName('Endpoint aliases')
.setHeading();
const filterEl = containerEl.createEl('input', {
cls: 'data-fetcher-endpoint-filter',
attr: {
type: 'text',
placeholder: 'Filter endpoints by alias, type, or URL...'
}
});
filterEl.value = this.endpointFilter;
filterEl.addEventListener('input', () => {
this.endpointFilter = filterEl.value;
this.renderEndpointTable(containerEl);
});
this.renderEndpointTable(containerEl);
new Setting(containerEl)
@ -889,7 +836,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
.setCta()
.onClick(() => {
const newEndpoint = this.buildDefaultEndpoint();
new EndpointEditorModal(this.app, newEndpoint, this.collectExistingAliases(), async (savedEndpoint) => {
new EndpointEditorModal(this.app, newEndpoint, async (savedEndpoint) => {
this.plugin.settings.endpoints.push(savedEndpoint);
await this.plugin.saveSettings();
this.display();
@ -907,21 +854,6 @@ class DataFetcherSettingTab extends PluginSettingTab {
};
}
private collectExistingAliases(excludeAlias?: string): Set<string> {
const aliases = new Set<string>();
for (const endpoint of this.plugin.settings.endpoints) {
const alias = endpoint.alias?.trim();
if (!alias) {
continue;
}
if (excludeAlias && alias === excludeAlias) {
continue;
}
aliases.add(alias);
}
return aliases;
}
private endpointTypeLabel(type: EndpointConfig['type']): string {
if (type === 'rest') return 'REST';
if (type === 'graphql') return 'GraphQL';
@ -936,7 +868,6 @@ class DataFetcherSettingTab extends PluginSettingTab {
}
private renderEndpointTable(containerEl: HTMLElement): void {
containerEl.querySelector('.data-fetcher-endpoint-table')?.remove();
const table = containerEl.createEl('div', { cls: 'data-fetcher-endpoint-table' });
const header = table.createEl('div', { cls: 'data-fetcher-endpoint-row data-fetcher-endpoint-row-header' });
header.createEl('div', { text: 'Name', cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-alias' });
@ -951,28 +882,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
return;
}
const normalizedFilter = this.endpointFilter.trim().toLowerCase();
const visibleEndpoints = this.plugin.settings.endpoints
.map((endpoint, index) => ({ endpoint, index }))
.filter(({ endpoint }) => {
if (!normalizedFilter) {
return true;
}
return [
endpoint.alias || '',
endpoint.type || '',
endpoint.url || ''
].join(' ').toLowerCase().includes(normalizedFilter);
});
if (visibleEndpoints.length === 0) {
const empty = table.createEl('div', { cls: 'data-fetcher-endpoint-empty' });
empty.setText('No endpoints match current filter.');
return;
}
visibleEndpoints.forEach(({ endpoint, index }) => {
this.plugin.settings.endpoints.forEach((endpoint, index) => {
const row = table.createEl('div', { cls: 'data-fetcher-endpoint-row' });
row.createEl('div', {
text: endpoint.alias?.trim() || '(unnamed)',
@ -996,7 +906,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
actions.createEl('button', { text: 'Edit' }).addEventListener('click', () => {
const draft = { ...endpoint, headers: { ...(endpoint.headers || {}) } };
new EndpointEditorModal(this.app, draft, this.collectExistingAliases(endpoint.alias), async (savedEndpoint) => {
new EndpointEditorModal(this.app, draft, async (savedEndpoint) => {
this.plugin.settings.endpoints[index] = savedEndpoint;
await this.plugin.saveSettings();
this.display();
@ -1010,7 +920,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
alias: duplicateAlias,
headers: { ...(endpoint.headers || {}) }
};
new EndpointEditorModal(this.app, draft, this.collectExistingAliases(), async (savedEndpoint) => {
new EndpointEditorModal(this.app, draft, async (savedEndpoint) => {
this.plugin.settings.endpoints.push(savedEndpoint);
await this.plugin.saveSettings();
this.display();
@ -1030,196 +940,6 @@ class DataFetcherSettingTab extends PluginSettingTab {
});
}
private endpointForExport(endpoint: EndpointConfig, includeHeaders: boolean): EndpointConfig {
return {
...endpoint,
headers: includeHeaders ? { ...(endpoint.headers || {}) } : {}
};
}
private exportEndpoints(includeHeaders: boolean): void {
const payload = {
version: 1,
exportedAt: new Date().toISOString(),
includesHeaders: includeHeaders,
endpoints: this.plugin.settings.endpoints.map(endpoint => this.endpointForExport(endpoint, includeHeaders))
};
const json = JSON.stringify(payload, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
anchor.href = url;
anchor.download = `data-fetcher-endpoints-${timestamp}.json`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
const headerMode = includeHeaders ? 'with headers' : 'without headers';
new Notice(`Exported ${this.plugin.settings.endpoints.length} endpoint(s) ${headerMode}`);
}
private readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('Failed to read file'));
reader.readAsText(file);
});
}
private normalizeHeaders(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const normalized: Record<string, string> = {};
for (const [key, headerValue] of Object.entries(value as Record<string, unknown>)) {
if (!key || typeof key !== 'string') {
continue;
}
if (headerValue === null || headerValue === undefined) {
continue;
}
normalized[key] = String(headerValue);
}
return normalized;
}
private parseEndpointImport(rawEndpoint: unknown): EndpointConfig | null {
if (!rawEndpoint || typeof rawEndpoint !== 'object' || Array.isArray(rawEndpoint)) {
return null;
}
const endpoint = rawEndpoint as Record<string, unknown>;
const alias = typeof endpoint.alias === 'string' ? endpoint.alias.trim() : '';
const url = typeof endpoint.url === 'string' ? endpoint.url.trim() : '';
const typeValue = typeof endpoint.type === 'string' ? endpoint.type : '';
const validTypes: EndpointConfig['type'][] = ['rest', 'graphql', 'grpc', 'rpc'];
if (!alias || !url || !validTypes.includes(typeValue as EndpointConfig['type'])) {
return null;
}
const type = typeValue as EndpointConfig['type'];
let method = typeof endpoint.method === 'string' ? endpoint.method.toUpperCase() : 'GET';
if (type === 'graphql' || type === 'grpc' || type === 'rpc') {
method = 'POST';
}
const normalized: EndpointConfig = {
alias,
url,
method,
type,
headers: this.normalizeHeaders(endpoint.headers)
};
if (typeof endpoint.body === 'string') {
normalized.body = endpoint.body;
}
if (typeof endpoint.query === 'string') {
normalized.query = endpoint.query;
}
return normalized;
}
private extractImportedEndpoints(payload: unknown): unknown[] {
if (Array.isArray(payload)) {
return payload;
}
if (payload && typeof payload === 'object' && Array.isArray((payload as { endpoints?: unknown[] }).endpoints)) {
return (payload as { endpoints: unknown[] }).endpoints;
}
return [];
}
private dedupeImportedEndpoints(endpoints: EndpointConfig[]): { endpoints: EndpointConfig[]; duplicateAliases: number } {
const deduped = new Map<string, EndpointConfig>();
let duplicateAliases = 0;
for (const endpoint of endpoints) {
if (deduped.has(endpoint.alias)) {
duplicateAliases++;
}
deduped.set(endpoint.alias, endpoint);
}
return {
endpoints: Array.from(deduped.values()),
duplicateAliases
};
}
private async importEndpoints(): Promise<void> {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', async () => {
const selectedFile = input.files?.item(0);
if (!selectedFile) {
return;
}
try {
const text = await this.readFileAsText(selectedFile);
const parsed = JSON.parse(text) as unknown;
const rawEndpoints = this.extractImportedEndpoints(parsed);
if (rawEndpoints.length === 0) {
new Notice('No endpoints found in import file');
return;
}
const validEndpoints: EndpointConfig[] = [];
let skipped = 0;
for (const rawEndpoint of rawEndpoints) {
const endpoint = this.parseEndpointImport(rawEndpoint);
if (!endpoint) {
skipped++;
continue;
}
validEndpoints.push(endpoint);
}
if (validEndpoints.length === 0) {
new Notice('Import failed: no valid endpoints found');
return;
}
const dedupedImport = this.dedupeImportedEndpoints(validEndpoints);
const skippedTotal = skipped + dedupedImport.duplicateAliases;
new EndpointImportModeModal(this.app, dedupedImport.endpoints.length, skippedTotal, async (mode) => {
if (mode === 'replace') {
this.plugin.settings.endpoints = dedupedImport.endpoints;
} else {
const merged = [...this.plugin.settings.endpoints];
for (const endpoint of dedupedImport.endpoints) {
const existingIndex = merged.findIndex(item => item.alias === endpoint.alias);
if (existingIndex >= 0) {
merged[existingIndex] = endpoint;
} else {
merged.push(endpoint);
}
}
this.plugin.settings.endpoints = merged;
}
await this.plugin.saveSettings();
this.display();
const modeLabel = mode === 'replace' ? 'replaced' : 'merged';
new Notice(`Imported ${dedupedImport.endpoints.length} endpoint(s), ${skippedTotal} skipped (${modeLabel})`);
}).open();
} catch (error) {
new Notice(`Import failed: ${error.message}`);
}
}, { once: true });
input.click();
}
async updateCacheInfo(containerEl: HTMLElement) {
const cacheInfo = await this.plugin.cacheManager.getCacheInfo();
containerEl.empty();
@ -1236,164 +956,19 @@ class DataFetcherSettingTab extends PluginSettingTab {
}
}
class EndpointImportModeModal extends Modal {
private validCount: number;
private skippedCount: number;
private onSubmit: (mode: 'merge' | 'replace') => Promise<void>;
constructor(app: App, validCount: number, skippedCount: number, onSubmit: (mode: 'merge' | 'replace') => Promise<void>) {
super(app);
this.validCount = validCount;
this.skippedCount = skippedCount;
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
new Setting(contentEl)
.setName('Import endpoints')
.setHeading();
contentEl.createEl('p', {
text: `Valid endpoints: ${this.validCount}. Skipped entries: ${this.skippedCount}.`
});
contentEl.createEl('p', {
text: 'Merge updates existing aliases and adds new ones. Replace overwrites all current endpoints.'
});
const actions = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-editor-actions' });
actions.createEl('button', { text: 'Cancel' }).addEventListener('click', () => this.close());
actions.createEl('button', { text: 'Merge', cls: 'mod-cta' }).addEventListener('click', async () => {
await this.onSubmit('merge');
this.close();
});
actions.createEl('button', { text: 'Replace', cls: 'mod-warning' }).addEventListener('click', async () => {
await this.onSubmit('replace');
this.close();
});
}
onClose() {
this.contentEl.empty();
}
}
class EndpointExportModal extends Modal {
private endpoints: EndpointConfig[];
private onSubmit: (includeHeaders: boolean) => void;
private includeHeaders = false;
constructor(app: App, endpoints: EndpointConfig[], onSubmit: (includeHeaders: boolean) => void) {
super(app);
this.endpoints = endpoints;
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
new Setting(contentEl)
.setName('Export endpoints')
.setHeading();
contentEl.createEl('p', {
text: `Export ${this.endpoints.length} endpoint(s) to JSON. Headers may contain API keys or tokens.`
});
new Setting(contentEl)
.setName('Include headers')
.setDesc('Disabled by default to avoid exporting secrets such as Authorization headers.')
.addToggle(toggle => toggle
.setValue(this.includeHeaders)
.onChange(value => {
this.includeHeaders = value;
}));
const actions = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-editor-actions' });
actions.createEl('button', { text: 'Cancel' }).addEventListener('click', () => this.close());
actions.createEl('button', { text: 'Export', cls: 'mod-cta' }).addEventListener('click', () => {
this.onSubmit(this.includeHeaders);
this.close();
});
}
onClose() {
this.contentEl.empty();
}
}
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>;
private onSubmit: (endpoint: EndpointConfig) => void;
private methodSettingEl: HTMLElement | null = null;
private headersSummaryEl: HTMLElement | null = null;
private rpcMethodNoticeEl: HTMLElement | null = null;
constructor(app: App, endpoint: EndpointConfig, existingAliases: Set<string>, onSubmit: (endpoint: EndpointConfig) => void) {
constructor(app: App, endpoint: EndpointConfig, onSubmit: (endpoint: EndpointConfig) => void) {
super(app);
this.endpoint = {
...endpoint,
headers: { ...(endpoint.headers || {}) },
method: endpoint.method || 'GET'
};
this.existingAliases = existingAliases;
this.onSubmit = onSubmit;
}
@ -1410,21 +985,8 @@ class EndpointEditorModal extends Modal {
this.methodSettingEl.remove();
this.methodSettingEl = null;
}
if (this.rpcMethodNoticeEl) {
this.rpcMethodNoticeEl.remove();
this.rpcMethodNoticeEl = null;
}
if (this.endpoint.type === 'rpc') {
this.endpoint.method = 'POST';
this.rpcMethodNoticeEl = containerEl.createDiv();
new Setting(this.rpcMethodNoticeEl)
.setName('Method')
.setDesc('JSON-RPC requests always use POST for compatibility across Obsidian desktop and mobile.');
return;
}
if (this.endpoint.type !== 'rest') {
if (this.endpoint.type !== 'rest' && this.endpoint.type !== 'rpc') {
return;
}
@ -1443,52 +1005,6 @@ 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();
@ -1525,7 +1041,7 @@ class EndpointEditorModal extends Modal {
this.renderMethodSetting(contentEl);
}));
const urlSetting = new Setting(contentEl)
new Setting(contentEl)
.setName('URL')
.setDesc('Endpoint URL')
.addText(text => text
@ -1534,7 +1050,6 @@ class EndpointEditorModal extends Modal {
.onChange(value => {
this.endpoint.url = value.trim();
}));
urlSetting.settingEl.addClass('data-fetcher-endpoint-url-setting');
this.renderMethodSetting(contentEl);
@ -1555,12 +1070,8 @@ 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) {
if (!this.endpoint.alias?.trim()) {
new Notice('Alias is required.');
return;
}
@ -1568,17 +1079,13 @@ class EndpointEditorModal extends Modal {
new Notice('URL is required.');
return;
}
if (this.existingAliases.has(normalizedAlias)) {
new Notice(`Alias "${normalizedAlias}" already exists.`);
return;
}
if (this.endpoint.type === 'graphql' || this.endpoint.type === 'grpc' || this.endpoint.type === 'rpc') {
if (this.endpoint.type === 'graphql' || this.endpoint.type === 'grpc') {
this.endpoint.method = 'POST';
}
this.onSubmit({
alias: normalizedAlias,
alias: this.endpoint.alias.trim(),
type: this.endpoint.type,
url: this.endpoint.url.trim(),
method: this.endpoint.method || 'GET',
@ -1601,7 +1108,7 @@ class CacheBrowserModal extends Modal {
private summaryContainer: HTMLElement;
private filterInput: HTMLInputElement;
private selectedCacheKey: string | null = null;
private entries: Array<{key: string; path: string; size: number; mtime: number; endpoint?: string; type?: QueryParams['type']; url?: string}> = [];
private entries: Array<{key: string; path: string; size: number; mtime: number}> = [];
constructor(app: App, cacheManager: CacheManager) {
super(app);
@ -1625,22 +1132,9 @@ class CacheBrowserModal extends Modal {
pre.createEl('code', { text });
}
private entryLabel(entry: { key: string; endpoint?: string }): string {
const endpointLabel = entry.endpoint && entry.endpoint !== 'direct' ? entry.endpoint : 'direct';
return `${endpointLabel} - ${entry.key}`;
}
private renderEntries(): void {
const filter = this.filterInput?.value?.trim().toLowerCase() || '';
const visibleEntries = this.entries.filter(entry => {
const searchable = [
entry.key,
entry.endpoint || '',
entry.type || '',
entry.url || ''
].join(' ').toLowerCase();
return searchable.includes(filter);
});
const visibleEntries = this.entries.filter(entry => entry.key.toLowerCase().includes(filter));
const totalSize = visibleEntries.reduce((sum, entry) => sum + entry.size, 0);
this.summaryContainer.empty();
this.summaryContainer.setText(`Entries: ${visibleEntries.length} (${this.formatBytes(totalSize)})`);
@ -1657,17 +1151,11 @@ class CacheBrowserModal extends Modal {
for (const entry of visibleEntries) {
const row = this.entriesContainer.createEl('div', { cls: 'data-fetcher-cache-row' });
const meta = row.createEl('div', { cls: 'data-fetcher-cache-row-meta' });
meta.createEl('div', { text: this.entryLabel(entry), cls: 'data-fetcher-cache-key' });
meta.createEl('div', { text: entry.key, cls: 'data-fetcher-cache-key' });
meta.createEl('div', {
text: `${entry.type ? `${entry.type.toUpperCase()} | ` : ''}${this.formatDate(entry.mtime)} | ${this.formatBytes(entry.size)}`,
text: `${this.formatDate(entry.mtime)} | ${this.formatBytes(entry.size)}`,
cls: 'data-fetcher-cache-meta'
});
if (entry.url) {
meta.createEl('div', {
text: entry.url,
cls: 'data-fetcher-cache-meta data-fetcher-cache-url'
});
}
const actions = row.createEl('div', { cls: 'data-fetcher-cache-row-actions' });
actions.createEl('button', { text: 'Preview' }).addEventListener('click', async () => {
@ -1720,7 +1208,7 @@ class CacheBrowserModal extends Modal {
cls: 'data-fetcher-cache-filter',
attr: {
type: 'text',
placeholder: 'Filter by alias, key, type, or URL...'
placeholder: 'Filter by cache key...'
}
});
this.filterInput.addEventListener('input', () => this.renderEntries());

View file

@ -1,7 +1,7 @@
{
"id": "data-fetcher",
"name": "Data Fetcher",
"version": "1.2.1",
"version": "1.1.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.2.1",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-api-fetcher",
"version": "1.2.1",
"version": "1.1.1",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",

View file

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

View file

@ -0,0 +1,52 @@
param(
[Parameter(Mandatory = $true)]
[string]$Version
)
$ErrorActionPreference = "Stop"
function Require-CleanTree {
$status = git status --porcelain
if ($LASTEXITCODE -ne 0) {
throw "Failed to read git status."
}
$dirty = $status | Where-Object { $_ -notmatch '^\?\?\s+DEV_NOTES\.md$' }
if ($dirty) {
throw "Working tree is not clean. Commit/stash changes before running release script."
}
}
function Require-Ref([string]$RefName) {
git rev-parse --verify $RefName *> $null
if ($LASTEXITCODE -ne 0) {
throw "Missing required ref: $RefName"
}
}
Write-Host "==> Releasing version $Version"
Require-CleanTree
Write-Host "==> Internal release on origin/main"
git checkout main
git pull origin main
cmd /c npx tsc -noEmit -skipLibCheck
cmd /c npm run build
git add manifest.json versions.json package.json package-lock.json CHANGELOG.md README.md
git commit -m "release: $Version"
git tag $Version
git push origin main --tags
Write-Host "==> Preparing PR branch from public/main"
git fetch public --tags
Require-Ref "public/main"
Require-Ref $Version
$branch = "public-fix-$Version"
git checkout -B $branch public/main
git checkout $Version -- manifest.json versions.json package.json package-lock.json
git add manifest.json versions.json package.json package-lock.json
git commit -m "public: bump release metadata to $Version"
git push public $branch
Write-Host "==> Open PR:"
Write-Host "https://github.com/qf3l3k/obsidian-data-fetcher/pull/new/$branch"

55
scripts/release-public.sh Normal file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version>" >&2
echo "Example: $0 1.0.7" >&2
exit 1
fi
V="$1"
require_clean_tree() {
local dirty
dirty="$(git status --porcelain | grep -vE '^\?\? DEV_NOTES\.md$' || true)"
if [[ -n "$dirty" ]]; then
echo "Working tree is not clean. Commit/stash changes before running release script." >&2
exit 1
fi
}
require_ref() {
local ref="$1"
if ! git rev-parse --verify "$ref" >/dev/null 2>&1; then
echo "Missing required ref: $ref" >&2
exit 1
fi
}
echo "==> Releasing version $V"
require_clean_tree
echo "==> Internal release on origin/main"
git checkout main
git pull origin main
npx tsc -noEmit -skipLibCheck
npm run build
git add manifest.json versions.json package.json package-lock.json CHANGELOG.md README.md
git commit -m "release: $V"
git tag "$V"
git push origin main --tags
echo "==> Preparing PR branch from public/main"
git fetch public --tags
require_ref "public/main"
require_ref "$V"
BRANCH="public-fix-$V"
git checkout -B "$BRANCH" public/main
git checkout "$V" -- manifest.json versions.json package.json package-lock.json
git add manifest.json versions.json package.json package-lock.json
git commit -m "public: bump release metadata to $V"
git push public "$BRANCH"
echo "==> Open PR:"
echo "https://github.com/qf3l3k/obsidian-data-fetcher/pull/new/$BRANCH"

View file

@ -1,16 +1,6 @@
import { App, TFile, TFolder } from 'obsidian';
import { QueryParams, QueryResult } from './queryEngine';
interface CacheEntryMetadata {
endpoint?: string;
type?: QueryParams['type'];
url?: string;
}
interface StoredCacheEntry extends QueryResult {
_meta?: CacheEntryMetadata;
}
export class CacheManager {
private app: App;
private plugin: any;
@ -43,11 +33,9 @@ export class CacheManager {
private generateCacheKey(params: QueryParams): string {
// Create a unique key based on query parameters
const stringToHash = JSON.stringify({
endpoint: params.endpoint,
url: params.url,
type: params.type,
method: params.method,
headers: params.headers,
body: params.body,
query: params.query,
variables: params.variables
@ -84,7 +72,7 @@ export class CacheManager {
// Read the cache file
const cacheContent = await this.app.vault.read(cacheFile);
const cacheData = JSON.parse(cacheContent) as StoredCacheEntry;
const cacheData = JSON.parse(cacheContent);
// Check if cache is expired
const now = Date.now();
@ -95,11 +83,7 @@ export class CacheManager {
return null; // Cache is expired
}
return {
data: cacheData.data,
timestamp: cacheData.timestamp,
error: cacheData.error
};
return cacheData;
} catch (error) {
console.error('Error reading from cache:', error);
return null;
@ -114,17 +98,9 @@ export class CacheManager {
await this.ensureCacheFolder();
const cacheKey = this.generateCacheKey(params);
const cacheFilePath = `${this.cacheFolder}/${cacheKey}.json`;
const storedEntry: StoredCacheEntry = {
...result,
_meta: {
endpoint: params.endpoint,
type: params.type,
url: params.url
}
};
// Create or overwrite the cache file
await this.app.vault.adapter.write(cacheFilePath, JSON.stringify(storedEntry));
await this.app.vault.adapter.write(cacheFilePath, JSON.stringify(result));
} catch (error) {
console.error('Error saving to cache:', error);
}
@ -192,11 +168,11 @@ export class CacheManager {
return `${this.cacheFolder}/${cacheKey}.json`;
}
async listCacheEntries(): Promise<Array<{key: string; path: string; size: number; mtime: number; endpoint?: string; type?: QueryParams['type']; url?: string}>> {
async listCacheEntries(): Promise<Array<{key: string; path: string; size: number; mtime: number}>> {
try {
await this.ensureCacheFolder();
const listed = await this.app.vault.adapter.list(this.cacheFolder);
const entries: Array<{key: string; path: string; size: number; mtime: number; endpoint?: string; type?: QueryParams['type']; url?: string}> = [];
const entries: Array<{key: string; path: string; size: number; mtime: number}> = [];
for (const filePath of listed.files) {
if (!filePath.endsWith('.json')) {
@ -213,23 +189,11 @@ export class CacheManager {
continue;
}
let metadata: CacheEntryMetadata | undefined;
try {
const content = await this.app.vault.adapter.read(filePath);
const parsed = JSON.parse(content) as StoredCacheEntry;
metadata = parsed._meta;
} catch (error) {
console.error(`Error reading cache metadata for ${filePath}:`, error);
}
entries.push({
key,
path: filePath,
size: stat.size,
mtime: stat.mtime,
endpoint: metadata?.endpoint,
type: metadata?.type,
url: metadata?.url
mtime: stat.mtime
});
}
@ -252,14 +216,10 @@ export class CacheManager {
}
const content = await this.app.vault.adapter.read(cacheFilePath);
const parsed = JSON.parse(content) as StoredCacheEntry;
const parsed = JSON.parse(content);
if (parsed && typeof parsed === 'object' && 'timestamp' in parsed) {
return {
data: parsed.data,
timestamp: parsed.timestamp,
error: parsed.error
};
return parsed as QueryResult;
}
return null;

View file

@ -7,7 +7,6 @@ export interface QueryParams {
output?: 'render' | 'frontmatter';
property?: string;
format?: 'json' | 'table';
template?: string;
path?: string;
url?: string;
method?: string;
@ -23,43 +22,6 @@ 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();
if (normalized.includes('resource exceeds maximum size')) {
return 'Response too large for this device. Reduce payload size, add filters/limits, or use a proxy endpoint.';
}
return message;
}
function parseOutputFormat(value: string): 'json' | 'table' {
const normalized = value.trim().toLowerCase();
if (normalized === 'json' || normalized === 'table') {
@ -107,32 +69,6 @@ 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
*/
@ -175,39 +111,44 @@ 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();
if ((normalizedKey === 'query' || normalizedKey === 'template') && i + 1 < lines.length) {
const collected = collectMultilineValue(lines, i, value);
value = collected.value;
i = collected.endIndex;
// 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 === 'body') {
if (key.trim() === 'body') {
try {
queryParams.body = JSON.parse(value);
} catch {
queryParams.body = value;
}
} else if (normalizedKey === 'query') {
} else if (key.trim() === 'query') {
queryParams.query = value;
} else if (normalizedKey === 'variables') {
} else if (key.trim() === 'variables') {
try {
queryParams.variables = JSON.parse(value);
} catch {
throw new Error('Variables must be valid JSON');
}
} else if (normalizedKey === 'path') {
} else if (key.trim() === 'path') {
queryParams.path = value;
} else if (normalizedKey === 'format') {
} else if (key.trim() === 'format') {
queryParams.format = parseOutputFormat(value);
} else if (normalizedKey === 'output') {
} else if (key.trim() === 'output') {
queryParams.output = parseOutputTarget(value);
} else if (normalizedKey === 'property') {
} else if (key.trim() === 'property') {
queryParams.property = value;
} else if (normalizedKey === 'template') {
queryParams.template = value;
}
}
}
@ -281,7 +222,7 @@ export async function executeQuery(params: QueryParams): Promise<QueryResult> {
return {
data: null,
timestamp: Date.now(),
error: formatRequestError(error)
error: error.message
};
}
}
@ -317,17 +258,14 @@ async function executeRestQuery(params: QueryParams): Promise<any> {
const response = await requestUrl(requestParams);
// 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')) {
// Parse response based on content type
const contentType = response.headers['content-type'];
if (contentType && contentType.includes('application/json')) {
return response.json;
} else {
return response.text;
}
const parsedText = tryParseJsonText(response.text);
return parsedText !== undefined ? parsedText : response.text;
}
/**
@ -395,7 +333,7 @@ async function executeRpcQuery(params: QueryParams): Promise<any> {
const requestParams: RequestUrlParam = {
url: params.url,
method: 'POST',
method: params.method || 'POST',
headers: {
'Content-Type': 'application/json',
...(params.headers || {})

View file

@ -147,11 +147,6 @@
font-size: var(--font-ui-smaller);
}
.data-fetcher-endpoint-filter {
width: 100%;
margin-bottom: 10px;
}
.data-fetcher-endpoint-row {
display: grid;
grid-template-columns: minmax(84px, 1fr) 72px minmax(140px, 2fr) 64px minmax(150px, 1.2fr);
@ -206,56 +201,10 @@
}
.data-fetcher-endpoint-editor-modal {
width: min(920px, 94vw);
max-width: min(920px, 94vw);
}
.data-fetcher-endpoint-url-setting .setting-item-control {
flex: 1 1 560px;
}
.data-fetcher-endpoint-url-setting .setting-item-control input[type="text"] {
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;
@ -291,14 +240,14 @@
.modal.mod-sidebar-layout.data-fetcher-cache-browser-modal,
.data-fetcher-cache-browser-modal {
width: min(1120px, 94vw);
max-width: min(1120px, 94vw);
width: min(1200px, 94vw);
max-width: min(1200px, 94vw);
}
.data-fetcher-cache-browser {
width: min(1080px, 92vw);
max-width: min(1080px, 92vw);
min-height: 74vh;
width: min(1160px, 92vw);
max-width: min(1160px, 92vw);
min-height: 70vh;
}
.data-fetcher-cache-toolbar {
@ -320,10 +269,9 @@
.data-fetcher-cache-split {
display: grid;
grid-template-columns: minmax(360px, 42%) minmax(0, 1fr);
grid-template-columns: minmax(320px, 38%) 1fr;
gap: 12px;
height: 68vh;
min-height: 0;
height: 62vh;
}
.data-fetcher-cache-list,
@ -332,9 +280,8 @@
border-radius: 6px;
background-color: var(--background-secondary);
padding: 10px;
max-height: 420px;
overflow: auto;
min-height: 0;
height: 100%;
}
.data-fetcher-cache-row {
@ -366,13 +313,6 @@
font-size: 0.8em;
}
.data-fetcher-cache-url {
margin-top: 4px;
font-family: var(--font-monospace);
font-size: 0.75em;
overflow-wrap: anywhere;
}
.data-fetcher-cache-row-actions {
display: flex;
gap: 6px;

View file

@ -5,11 +5,5 @@
"1.0.8": "1.8.3",
"1.0.9": "1.8.3",
"1.1.0": "1.8.3",
"1.1.1": "1.8.3",
"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.2.0": "1.8.3",
"1.2.1": "1.8.3"
"1.1.1": "1.8.3"
}