mirror of
https://github.com/qf3l3k/obsidian-data-fetcher.git
synced 2026-07-22 05:43:10 +00:00
Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ef155df4 | ||
|
|
ac1e6e239a | ||
|
|
121562cfaf | ||
|
|
2b1c33479d | ||
|
|
69dfd41ee8 | ||
|
|
6a839eeaea | ||
|
|
5d2cc8fb58 | ||
|
|
7442d1d615 | ||
|
|
011a291cfc | ||
|
|
2ede0de6ca | ||
|
|
2b34b342ef | ||
|
|
c64aaf495d | ||
|
|
2a14c38b3b | ||
|
|
b7baa0b6e6 | ||
|
|
78dc3e3a47 | ||
|
|
c64ae0ccfa | ||
|
|
c1b229479d | ||
|
|
2f9dad1706 | ||
|
|
6e88b220ee |
14 changed files with 1528 additions and 516 deletions
431
.gitignore
vendored
431
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
66
CHANGELOG.md
66
CHANGELOG.md
|
|
@ -2,7 +2,61 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
## [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.
|
||||
|
||||
## [1.1.1] - 2026-03-03
|
||||
|
||||
|
|
@ -15,7 +69,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-02
|
||||
## [1.1.0] - 2026-03-03
|
||||
|
||||
### Added
|
||||
- Issue #5 (cache management): added Cache Browser modal with entry list, payload preview, single-entry deletion, and clear-all action.
|
||||
|
|
@ -24,12 +78,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-02
|
||||
## [1.0.9] - 2026-03-03
|
||||
|
||||
### 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-02
|
||||
## [1.0.8] - 2026-03-03
|
||||
|
||||
### Added
|
||||
- Issue #6: added optional `path` selector and `format: table` rendering for array-of-object JSON responses.
|
||||
|
|
@ -38,7 +92,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-02
|
||||
## [1.0.7] - 2026-03-03
|
||||
|
||||
### Added
|
||||
- Issue #5: support inline call-site variables for endpoint aliases using `@alias({...})` and `=@alias({...})`.
|
||||
|
|
@ -46,7 +100,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-02
|
||||
## [1.0.6] - 2026-03-03
|
||||
|
||||
### 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
432
README.md
|
|
@ -1,55 +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.
|
||||
|
||||
[](https://buymeacoffee.com/qf3l3k)
|
||||
<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
|
||||
- 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"
|
||||
|
|
@ -57,196 +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
|
||||
- `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
|
||||
- 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
|
||||
- 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.
|
||||
- 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`.
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
# 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
634
main.ts
|
|
@ -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';
|
||||
|
|
@ -21,7 +21,6 @@ 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' });
|
||||
|
|
@ -445,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();
|
||||
|
|
@ -551,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)
|
||||
|
|
@ -674,49 +738,8 @@ export default class DataFetcherPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
// If exact block location is unavailable, avoid replacing the wrong data-query block.
|
||||
const cursor = editor.getCursor();
|
||||
|
||||
editor.transaction({
|
||||
changes: [
|
||||
|
|
@ -728,7 +751,7 @@ export default class DataFetcherPlugin extends Plugin {
|
|||
]
|
||||
});
|
||||
|
||||
new Notice('Data saved to note at cursor position');
|
||||
new Notice('Block location unavailable, so data was inserted at the cursor position');
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error saving data to note:", error);
|
||||
|
|
@ -751,6 +774,7 @@ export default class DataFetcherPlugin extends Plugin {
|
|||
|
||||
class DataFetcherSettingTab extends PluginSettingTab {
|
||||
plugin: DataFetcherPlugin;
|
||||
private endpointFilter = '';
|
||||
|
||||
constructor(app: App, plugin: DataFetcherPlugin) {
|
||||
super(app, plugin);
|
||||
|
|
@ -822,12 +846,41 @@ 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)
|
||||
|
|
@ -836,7 +889,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
|
|||
.setCta()
|
||||
.onClick(() => {
|
||||
const newEndpoint = this.buildDefaultEndpoint();
|
||||
new EndpointEditorModal(this.app, newEndpoint, async (savedEndpoint) => {
|
||||
new EndpointEditorModal(this.app, newEndpoint, this.collectExistingAliases(), async (savedEndpoint) => {
|
||||
this.plugin.settings.endpoints.push(savedEndpoint);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
|
|
@ -854,6 +907,21 @@ 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';
|
||||
|
|
@ -868,6 +936,7 @@ 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' });
|
||||
|
|
@ -882,7 +951,28 @@ class DataFetcherSettingTab extends PluginSettingTab {
|
|||
return;
|
||||
}
|
||||
|
||||
this.plugin.settings.endpoints.forEach((endpoint, index) => {
|
||||
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 }) => {
|
||||
const row = table.createEl('div', { cls: 'data-fetcher-endpoint-row' });
|
||||
row.createEl('div', {
|
||||
text: endpoint.alias?.trim() || '(unnamed)',
|
||||
|
|
@ -906,7 +996,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
|
|||
|
||||
actions.createEl('button', { text: 'Edit' }).addEventListener('click', () => {
|
||||
const draft = { ...endpoint, headers: { ...(endpoint.headers || {}) } };
|
||||
new EndpointEditorModal(this.app, draft, async (savedEndpoint) => {
|
||||
new EndpointEditorModal(this.app, draft, this.collectExistingAliases(endpoint.alias), async (savedEndpoint) => {
|
||||
this.plugin.settings.endpoints[index] = savedEndpoint;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
|
|
@ -920,7 +1010,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
|
|||
alias: duplicateAlias,
|
||||
headers: { ...(endpoint.headers || {}) }
|
||||
};
|
||||
new EndpointEditorModal(this.app, draft, async (savedEndpoint) => {
|
||||
new EndpointEditorModal(this.app, draft, this.collectExistingAliases(), async (savedEndpoint) => {
|
||||
this.plugin.settings.endpoints.push(savedEndpoint);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
|
|
@ -940,6 +1030,196 @@ 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();
|
||||
|
|
@ -956,19 +1236,164 @@ 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, onSubmit: (endpoint: EndpointConfig) => void) {
|
||||
constructor(app: App, endpoint: EndpointConfig, existingAliases: Set<string>, onSubmit: (endpoint: EndpointConfig) => void) {
|
||||
super(app);
|
||||
this.endpoint = {
|
||||
...endpoint,
|
||||
headers: { ...(endpoint.headers || {}) },
|
||||
method: endpoint.method || 'GET'
|
||||
};
|
||||
this.existingAliases = existingAliases;
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
|
|
@ -985,8 +1410,21 @@ class EndpointEditorModal extends Modal {
|
|||
this.methodSettingEl.remove();
|
||||
this.methodSettingEl = null;
|
||||
}
|
||||
if (this.rpcMethodNoticeEl) {
|
||||
this.rpcMethodNoticeEl.remove();
|
||||
this.rpcMethodNoticeEl = null;
|
||||
}
|
||||
|
||||
if (this.endpoint.type !== 'rest' && this.endpoint.type !== 'rpc') {
|
||||
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') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1005,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();
|
||||
|
|
@ -1041,7 +1525,7 @@ class EndpointEditorModal extends Modal {
|
|||
this.renderMethodSetting(contentEl);
|
||||
}));
|
||||
|
||||
new Setting(contentEl)
|
||||
const urlSetting = new Setting(contentEl)
|
||||
.setName('URL')
|
||||
.setDesc('Endpoint URL')
|
||||
.addText(text => text
|
||||
|
|
@ -1050,6 +1534,7 @@ class EndpointEditorModal extends Modal {
|
|||
.onChange(value => {
|
||||
this.endpoint.url = value.trim();
|
||||
}));
|
||||
urlSetting.settingEl.addClass('data-fetcher-endpoint-url-setting');
|
||||
|
||||
this.renderMethodSetting(contentEl);
|
||||
|
||||
|
|
@ -1070,8 +1555,12 @@ 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', () => {
|
||||
if (!this.endpoint.alias?.trim()) {
|
||||
const normalizedAlias = this.endpoint.alias?.trim();
|
||||
if (!normalizedAlias) {
|
||||
new Notice('Alias is required.');
|
||||
return;
|
||||
}
|
||||
|
|
@ -1079,13 +1568,17 @@ 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') {
|
||||
if (this.endpoint.type === 'graphql' || this.endpoint.type === 'grpc' || this.endpoint.type === 'rpc') {
|
||||
this.endpoint.method = 'POST';
|
||||
}
|
||||
|
||||
this.onSubmit({
|
||||
alias: this.endpoint.alias.trim(),
|
||||
alias: normalizedAlias,
|
||||
type: this.endpoint.type,
|
||||
url: this.endpoint.url.trim(),
|
||||
method: this.endpoint.method || 'GET',
|
||||
|
|
@ -1108,7 +1601,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}> = [];
|
||||
private entries: Array<{key: string; path: string; size: number; mtime: number; endpoint?: string; type?: QueryParams['type']; url?: string}> = [];
|
||||
|
||||
constructor(app: App, cacheManager: CacheManager) {
|
||||
super(app);
|
||||
|
|
@ -1132,9 +1625,22 @@ 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 => entry.key.toLowerCase().includes(filter));
|
||||
const visibleEntries = this.entries.filter(entry => {
|
||||
const searchable = [
|
||||
entry.key,
|
||||
entry.endpoint || '',
|
||||
entry.type || '',
|
||||
entry.url || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchable.includes(filter);
|
||||
});
|
||||
const totalSize = visibleEntries.reduce((sum, entry) => sum + entry.size, 0);
|
||||
this.summaryContainer.empty();
|
||||
this.summaryContainer.setText(`Entries: ${visibleEntries.length} (${this.formatBytes(totalSize)})`);
|
||||
|
|
@ -1151,11 +1657,17 @@ 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: entry.key, cls: 'data-fetcher-cache-key' });
|
||||
meta.createEl('div', { text: this.entryLabel(entry), cls: 'data-fetcher-cache-key' });
|
||||
meta.createEl('div', {
|
||||
text: `${this.formatDate(entry.mtime)} | ${this.formatBytes(entry.size)}`,
|
||||
text: `${entry.type ? `${entry.type.toUpperCase()} | ` : ''}${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 () => {
|
||||
|
|
@ -1208,7 +1720,7 @@ class CacheBrowserModal extends Modal {
|
|||
cls: 'data-fetcher-cache-filter',
|
||||
attr: {
|
||||
type: 'text',
|
||||
placeholder: 'Filter by cache key...'
|
||||
placeholder: 'Filter by alias, key, type, or URL...'
|
||||
}
|
||||
});
|
||||
this.filterInput.addEventListener('input', () => this.renderEntries());
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "data-fetcher",
|
||||
"name": "Data Fetcher",
|
||||
"version": "1.1.1",
|
||||
"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
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-api-fetcher",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-api-fetcher",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-api-fetcher",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.1",
|
||||
"description": "Fetch data from multiple sources (REST APIs, RPC, gRPC, GraphQL) and insert results into notes",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
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;
|
||||
|
|
@ -33,9 +43,11 @@ 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
|
||||
|
|
@ -72,7 +84,7 @@ export class CacheManager {
|
|||
|
||||
// Read the cache file
|
||||
const cacheContent = await this.app.vault.read(cacheFile);
|
||||
const cacheData = JSON.parse(cacheContent);
|
||||
const cacheData = JSON.parse(cacheContent) as StoredCacheEntry;
|
||||
|
||||
// Check if cache is expired
|
||||
const now = Date.now();
|
||||
|
|
@ -83,7 +95,11 @@ export class CacheManager {
|
|||
return null; // Cache is expired
|
||||
}
|
||||
|
||||
return cacheData;
|
||||
return {
|
||||
data: cacheData.data,
|
||||
timestamp: cacheData.timestamp,
|
||||
error: cacheData.error
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading from cache:', error);
|
||||
return null;
|
||||
|
|
@ -98,9 +114,17 @@ 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(result));
|
||||
await this.app.vault.adapter.write(cacheFilePath, JSON.stringify(storedEntry));
|
||||
} catch (error) {
|
||||
console.error('Error saving to cache:', error);
|
||||
}
|
||||
|
|
@ -168,11 +192,11 @@ export class CacheManager {
|
|||
return `${this.cacheFolder}/${cacheKey}.json`;
|
||||
}
|
||||
|
||||
async listCacheEntries(): Promise<Array<{key: string; path: string; size: number; mtime: number}>> {
|
||||
async listCacheEntries(): Promise<Array<{key: string; path: string; size: number; mtime: number; endpoint?: string; type?: QueryParams['type']; url?: string}>> {
|
||||
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}> = [];
|
||||
const entries: Array<{key: string; path: string; size: number; mtime: number; endpoint?: string; type?: QueryParams['type']; url?: string}> = [];
|
||||
|
||||
for (const filePath of listed.files) {
|
||||
if (!filePath.endsWith('.json')) {
|
||||
|
|
@ -189,11 +213,23 @@ 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
|
||||
mtime: stat.mtime,
|
||||
endpoint: metadata?.endpoint,
|
||||
type: metadata?.type,
|
||||
url: metadata?.url
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -216,10 +252,14 @@ export class CacheManager {
|
|||
}
|
||||
|
||||
const content = await this.app.vault.adapter.read(cacheFilePath);
|
||||
const parsed = JSON.parse(content);
|
||||
const parsed = JSON.parse(content) as StoredCacheEntry;
|
||||
|
||||
if (parsed && typeof parsed === 'object' && 'timestamp' in parsed) {
|
||||
return parsed as QueryResult;
|
||||
return {
|
||||
data: parsed.data,
|
||||
timestamp: parsed.timestamp,
|
||||
error: parsed.error
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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,43 @@ 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') {
|
||||
|
|
@ -69,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
|
||||
*/
|
||||
|
|
@ -111,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -222,7 +281,7 @@ export async function executeQuery(params: QueryParams): Promise<QueryResult> {
|
|||
return {
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
error: error.message
|
||||
error: formatRequestError(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -258,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -333,7 +395,7 @@ async function executeRpcQuery(params: QueryParams): Promise<any> {
|
|||
|
||||
const requestParams: RequestUrlParam = {
|
||||
url: params.url,
|
||||
method: params.method || 'POST',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(params.headers || {})
|
||||
|
|
|
|||
76
styles.css
76
styles.css
|
|
@ -147,6 +147,11 @@
|
|||
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);
|
||||
|
|
@ -201,10 +206,56 @@
|
|||
}
|
||||
|
||||
.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;
|
||||
|
|
@ -240,14 +291,14 @@
|
|||
|
||||
.modal.mod-sidebar-layout.data-fetcher-cache-browser-modal,
|
||||
.data-fetcher-cache-browser-modal {
|
||||
width: min(1200px, 94vw);
|
||||
max-width: min(1200px, 94vw);
|
||||
width: min(1120px, 94vw);
|
||||
max-width: min(1120px, 94vw);
|
||||
}
|
||||
|
||||
.data-fetcher-cache-browser {
|
||||
width: min(1160px, 92vw);
|
||||
max-width: min(1160px, 92vw);
|
||||
min-height: 70vh;
|
||||
width: min(1080px, 92vw);
|
||||
max-width: min(1080px, 92vw);
|
||||
min-height: 74vh;
|
||||
}
|
||||
|
||||
.data-fetcher-cache-toolbar {
|
||||
|
|
@ -269,9 +320,10 @@
|
|||
|
||||
.data-fetcher-cache-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 38%) 1fr;
|
||||
grid-template-columns: minmax(360px, 42%) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
height: 62vh;
|
||||
height: 68vh;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-fetcher-cache-list,
|
||||
|
|
@ -280,8 +332,9 @@
|
|||
border-radius: 6px;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 10px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.data-fetcher-cache-row {
|
||||
|
|
@ -313,6 +366,13 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -5,5 +5,11 @@
|
|||
"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.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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue