Compare commits

..

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

11 changed files with 247 additions and 1202 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,43 +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.
## [Unreleased]
## [1.1.3] - 2026-03-21

431
README.md
View file

@ -1,95 +1,59 @@
# 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>
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
- Compact endpoint list with modal editor (v1.1.1)
- Endpoint import/export for moving aliases between devices (v1.1.2)
- 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 +61,199 @@ 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
- `method`: always `POST`
- `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 (compact list with Name/Type/URL + actions)
- Endpoint import/export (JSON)
- 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.
- `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.
- `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.
- 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

304
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)
@ -774,7 +710,6 @@ export default class DataFetcherPlugin extends Plugin {
class DataFetcherSettingTab extends PluginSettingTab {
plugin: DataFetcherPlugin;
private endpointFilter = '';
constructor(app: App, plugin: DataFetcherPlugin) {
super(app, plugin);
@ -853,9 +788,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
.addButton(button => button
.setButtonText('Export endpoints')
.onClick(() => {
new EndpointExportModal(this.app, this.plugin.settings.endpoints, includeHeaders => {
this.exportEndpoints(includeHeaders);
}).open();
this.exportEndpoints();
}))
.addButton(button => button
.setButtonText('Import endpoints')
@ -868,19 +801,6 @@ class DataFetcherSettingTab extends PluginSettingTab {
.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)
@ -936,7 +856,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 +870,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)',
@ -1030,19 +928,11 @@ class DataFetcherSettingTab extends PluginSettingTab {
});
}
private endpointForExport(endpoint: EndpointConfig, includeHeaders: boolean): EndpointConfig {
return {
...endpoint,
headers: includeHeaders ? { ...(endpoint.headers || {}) } : {}
};
}
private exportEndpoints(includeHeaders: boolean): void {
private exportEndpoints(): void {
const payload = {
version: 1,
exportedAt: new Date().toISOString(),
includesHeaders: includeHeaders,
endpoints: this.plugin.settings.endpoints.map(endpoint => this.endpointForExport(endpoint, includeHeaders))
endpoints: this.plugin.settings.endpoints
};
const json = JSON.stringify(payload, null, 2);
const blob = new Blob([json], { type: 'application/json' });
@ -1055,8 +945,7 @@ class DataFetcherSettingTab extends PluginSettingTab {
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}`);
new Notice(`Exported ${this.plugin.settings.endpoints.length} endpoint(s)`);
}
private readFileAsText(file: File): Promise<string> {
@ -1280,104 +1169,6 @@ class EndpointImportModeModal extends Modal {
}
}
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>;
@ -1443,52 +1234,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 +1270,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 +1279,6 @@ class EndpointEditorModal extends Modal {
.onChange(value => {
this.endpoint.url = value.trim();
}));
urlSetting.settingEl.addClass('data-fetcher-endpoint-url-setting');
this.renderMethodSetting(contentEl);
@ -1555,9 +1299,6 @@ 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) {
@ -1601,7 +1342,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 +1366,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 +1385,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 +1442,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.3",
"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.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-api-fetcher",
"version": "1.2.1",
"version": "1.1.3",
"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.3",
"description": "Fetch data from multiple sources (REST APIs, RPC, gRPC, GraphQL) and insert results into notes",
"main": "main.js",
"scripts": {

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;
@ -84,7 +74,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 +85,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 +100,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 +170,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 +191,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 +218,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,32 +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();
@ -107,32 +80,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 +122,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;
}
}
}
@ -317,17 +269,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;
}
/**

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

@ -7,9 +7,5 @@
"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.3": "1.8.3"
}